From 3a57ddb2bf6a4f090642952ad45b75017e1e0240 Mon Sep 17 00:00:00 2001 From: Nikita Timofeev Date: Fri, 23 Oct 2026 18:53:04 +0400 Subject: [PATCH 1/2] CAY-3056 Switch DataMap queries to the new query parser --- RELEASE-NOTES.txt | 1 + UPGRADE.md | 8 + .../org/apache/cayenne/gen/DataMapUtils.java | 242 ++++++------ .../templates/v4_1/datamap-singleclass.vm | 4 +- .../templates/v4_1/datamap-superclass.vm | 4 +- .../gen/BaseTemplatesGenerationTest.java | 46 ++- .../apache/cayenne/gen/DataMapUtilsTest.java | 167 +++++---- .../templateTest/_SelectQueryResultTypes.java | 18 + .../_auto/_SelectQueryResultTypes.java | 40 ++ .../text/CMUndoableSyntaxTextArea.java | 79 ++++ .../modeler/toolkit/tree/EntityTreeModel.java | 20 + .../ui/project/editor/EditorPanelView.java | 10 +- .../query/ObjectQueryPropertiesPanel.java | 46 ++- .../project/editor/query/PrefetchModel.java | 14 +- .../editor/query/SelectPropertiesPanel.java | 2 +- ...eEdit.java => ObjectSelectTabbedView.java} | 37 +- .../query/selectquery/ObjectSelectView.java | 170 +++++++++ .../editor/query/selectquery/QLEditor.java | 149 ++++++++ .../query/selectquery/QLEditorParser.java | 175 +++++++++ .../query/selectquery/QLPathBrowser.java | 185 ++++++++++ .../query/selectquery/QLStatusLine.java | 97 +++++ .../query/selectquery/QLTokenMaker.java | 156 ++++++++ .../query/selectquery/SelectQueryMainTab.java | 286 -------------- .../selectquery/SelectQueryOrderingTab.java | 346 ----------------- .../selectquery/SelectQueryPrefetchTab.java | 175 --------- .../selectquery/SelectQueryTabbedView.java | 99 ----- .../sqltemplate/SQLTemplatePrefetchTab.java | 29 +- .../ui/project/querytype/QueryTypeDialog.java | 10 +- .../modeler/undo/TextCompoundEdit.java | 15 +- .../toolkit/tree/EntityTreeModelTest.java | 43 +++ .../query/selectquery/QLEditorParserTest.java | 124 +++++++ .../query/selectquery/QLTokenMakerTest.java | 115 ++++++ .../projecttools/validation/Inspection.java | 9 +- .../validation/SelectQueryPaths.java | 203 ++++++++++ .../validation/SelectQueryValidator.java | 103 ++---- .../validation/SelectQueryValidatorTest.java | 124 +++++++ .../cayenne/wocompat/EOModelProcessor.java | 23 +- .../EOFetchSpecificationParserTest.java | 10 +- .../java/org/apache/cayenne/map/DataMap.java | 17 +- .../cayenne/map/SelectQueryDescriptor.java | 348 ++++++++++++------ .../xml/SelectQueryDescriptorHandler.java | 77 +--- .../apache/cayenne/query/MappedSelect.java | 2 +- .../org/apache/cayenne/map/DataMapTest.java | 118 +++--- .../map/SelectQueryDescriptorTest.java | 335 ++++++++++++----- ...PrefetchTypeForSelectQueryHandlerTest.java | 10 +- .../xml/SelectQueryDescriptorHandlerTest.java | 85 ++--- .../project/xml/XMLDataMapLoaderTest.java | 2 +- .../apache/cayenne/query/MappedQueryIT.java | 41 +++ cayenne/src/test/resources/testmap.map.xml | 13 + .../_cayenne-guide/queries/mapped.adoc | 5 + 50 files changed, 2768 insertions(+), 1669 deletions(-) create mode 100644 cayenne-cgen/src/test/resources/templateTest/_SelectQueryResultTypes.java create mode 100644 cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/text/CMUndoableSyntaxTextArea.java rename cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/{AddPrefetchUndoableEdit.java => ObjectSelectTabbedView.java} (58%) create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/ObjectSelectView.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditor.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParser.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLPathBrowser.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java create mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMaker.java delete mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryMainTab.java delete mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryOrderingTab.java delete mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryPrefetchTab.java delete mode 100644 cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryTabbedView.java create mode 100644 cayenne-modeler/src/test/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModelTest.java create mode 100644 cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParserTest.java create mode 100644 cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMakerTest.java create mode 100644 cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java create mode 100644 cayenne-project-tools/src/test/java/org/apache/cayenne/projecttools/validation/SelectQueryValidatorTest.java diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index a9cf54cd1b..7826edadb4 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -51,6 +51,7 @@ CAY-3049 Cheap ClockMap to replace ConcurrentLinkedHashMap CAY-3050 Array-based DataRows CAY-3051 Replace PropertyVisitor with ClassDescriptor property collections CAY-3054 Remove ObjRelationship.isReadOnly() +CAY-3056 Switch DataMap queries to the new query parser CAY-3057 Improve parameter binding in the query parser Bug Fixes: diff --git a/UPGRADE.md b/UPGRADE.md index 2d1d44d65a..cc086345d7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -391,6 +391,14 @@ List artists = ObjectSelect `SelectQuery` in a DataMap is now stored as a single query String in the syntax of `ObjectSelect.parse(..)`, instead of the root attributes and the separate `qualifier`, `ordering` and `prefetch` elements. Projects are upgraded automatically, either in memory by the runtime, or permanently by CayenneModeler. +* `SelectQueryDescriptor` now holds the query String as is (`getQl()` / `setQl(..)`), and derives the rest from it. + The query String can define any `ObjectSelect` or `ColumnSelect`, including column queries, `having` and queries + rooted in a DbEntity (`from db:ARTIST`). Its root is defined by the "from" clause, so `setRoot(..)` is not supported. + The setters of the query parts (`setQualifier(..)`, `addOrdering(..)`, `addPrefetch(..)`, `setDistinct(..)`, etc.) + were removed and their getters are deprecated, use `getParsedQuery()` instead. `buildQuery(..)` returns a + `FluentSelect`, not an `ObjectSelect`. A query String that can't be parsed no longer fails the DataMap loading, but + the execution of the query, and is reported by the project validator. CayenneModeler edits a mapped `ObjectSelect` + as a query String. * Per [CAY-3038](https://issues.apache.org/jira/browse/CAY-3038) `EJBQLQuery` was removed. The replacement is `ObjectSelect` / `ColumnSelect` parsed from a String (see "Queries Parsed from Strings" above). As there is no automatic conversion of EJBQL to the new query language, an `EJBQLQuery` stored in a DataMap is dropped by the upgrade to project version 14 with a warning, and must be diff --git a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java index 17374748f1..215bc13f39 100644 --- a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java +++ b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java @@ -19,29 +19,32 @@ package org.apache.cayenne.gen; +import org.apache.cayenne.DataRow; +import org.apache.cayenne.exp.EnclosingObjectExp; import org.apache.cayenne.exp.Expression; import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ExpressionParameter; import org.apache.cayenne.exp.ListExp; import org.apache.cayenne.exp.ObjPathExp; +import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.exp.property.Property; +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.DbEntity; import org.apache.cayenne.map.ObjAttribute; import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.ObjRelationship; import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.query.Ordering; +import org.apache.cayenne.projecttools.validation.SelectQueryPaths; +import org.apache.cayenne.query.ColumnSelect; +import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.util.Util; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Attributes and Methods for working with Queries. @@ -50,7 +53,7 @@ */ public class DataMapUtils { - Map> queriesMap = new HashMap<>(); + private static final String OBJECT_TYPE = Object.class.getName(); /** * Return valid method name based on query name (replace all illegal @@ -64,86 +67,29 @@ public String getQueryMethodName(QueryDescriptor query) { } /** - * Get all parameter names that used in query qualifier. + * Get all parameter names used in the query, in the order of their first occurrence. * * @param query select query descriptor * @return Parameter names. */ public Collection getParameterNames(SelectQueryDescriptor query) { - - if (query.getQualifier() == null) { - return Collections.emptySet(); - } - - Map queryParameters = queriesMap.get(query.getName()); - - if (queryParameters == null) { - queryParameters = getParameterNames(query.getQualifier(), query.getRoot()); - queriesMap.put(query.getName(), queryParameters); - } - - return parseQualifier(query.getQualifier().toString()); - } - - public Boolean isValidParameterNames(SelectQueryDescriptor query) { - - if (query.getQualifier() == null) { - return true; - } - - Map queryParameters = queriesMap.get(query.getName()); - - if (queryParameters == null) { - try { - queryParameters = getParameterNames(query.getQualifier(), query.getRoot()); - } catch (Exception e) { - // if we have wrong path in queryParameters return false. - return false; - } - } - - for (Ordering ordering : query.getOrderings()) { - // validate paths in ordering - String path = ordering.getSortSpecString(); - try { - ((ObjEntity) query.getRoot()).resolvePath(path); - } catch (ExpressionException e) { - // if we have wrong path in orderings return false. - return false; - } - } - - return true; + return parameterTypes(query).keySet(); } /** - * Get list of parameter names in the same order as in qualifier. - * - * @param qualifierString - * to be parsed - * @return List of parameter names. + * Returns whether a method can be generated for the query: the query String can be parsed, its root is mapped, + * and the entity names and paths of all its clauses can be resolved. */ - private Set parseQualifier(String qualifierString) { - Set result = new LinkedHashSet<>(); - Pattern pattern = Pattern.compile("\\$[\\w]+"); - Matcher matcher = pattern.matcher(qualifierString); - while (matcher.find()) { - String name = matcher.group(); - result.add(Util.underscoredToJava(name.substring(1), false)); + public Boolean isValidParameterNames(SelectQueryDescriptor query) { + if (!query.isValid() || !(query.getRoot() instanceof ObjEntity || query.getRoot() instanceof DbEntity)) { + return false; } - return result; + return SelectQueryPaths.check(query).isEmpty(); } public boolean hasParameters(SelectQueryDescriptor query) { - Map queryParameters = queriesMap.get(query.getName()); - - if (queryParameters == null) { - return false; - } - - return queryParameters.keySet().size() > 0; - + return !getParameterNames(query).isEmpty(); } /** @@ -154,54 +100,134 @@ public boolean hasParameters(SelectQueryDescriptor query) { * @return Parameter type. */ public String getParameterType(SelectQueryDescriptor query, String name) { - return queriesMap.get(query.getName()).get(name); + return parameterTypes(query).get(name); + } + + /** + * Returns the fully qualified name of the type of the objects that the query fetches: the class of the root + * ObjEntity, a DataRow, an Object for a single column, or an Object[] for multiple columns. + * + * @since 5.0 + */ + public String getResultType(SelectQueryDescriptor query) { + FluentSelect parsed = query.getParsedQuery(); + if (parsed instanceof ColumnSelect columnSelect) { + return columnSelect.getColumns().size() == 1 ? OBJECT_TYPE : Object[].class.getCanonicalName(); + } + + if (query.getRoot() instanceof ObjEntity entity && !query.isFetchingDataRows()) { + return entity.getClassName(); + } + + return DataRow.class.getName(); } - private Map getParameterNames(Expression expression, Object root) { - if (expression != null) { - Map types = new HashMap<>(); - String typeName = ""; - List names = new LinkedList<>(); + // parameter types by the names of the parameters, in the order of their first occurrence + private Map parameterTypes(SelectQueryDescriptor query) { + if (!query.isValid()) { + return Map.of(); + } - for (int i = 0; i < expression.getOperandCount(); i++) { - Object operand = expression.getOperand(i); + Map types = new HashMap<>(); + collectParameterTypes(query.getDataMap(), query.getParsedQuery(), types); - if (operand instanceof Expression) { - types.putAll(getParameterNames((Expression) operand, root)); - } + Map ordered = new LinkedHashMap<>(); + for (String name : query.getParameterNames()) { + ordered.put(Util.underscoredToJava(name, false), types.getOrDefault(name, OBJECT_TYPE)); + } + return ordered; + } - if (operand instanceof ObjPathExp pathExp) { - Object component = ((ObjEntity) root) - .resolvePath(pathExp.getExpandedPath()) - .getLast(); - typeName = switch (component) { - case ObjAttribute attribute -> attribute.getType(); - case ObjRelationship relationship -> relationship.getTargetEntity().getClassName(); - default -> "Object"; - }; + private void collectParameterTypes(DataMap dataMap, FluentSelect query, Map types) { + // the type of a parameter can only be inferred from an ObjEntity attribute or relationship + ObjEntity entity = query.getEntityName() != null && dataMap != null + ? dataMap.getObjEntity(query.getEntityName()) + : null; + + // the aliases of the columns can be used in place of the paths elsewhere in the query + Map aliasTypes = new HashMap<>(); + if (query.getColumns() != null) { + for (Property column : query.getColumns()) { + if (column.getAlias() != null) { + aliasTypes.put(column.getAlias(), column.getExpression() instanceof ObjPathExp pathExp + ? pathType(entity, pathExp) + : OBJECT_TYPE); } + } + } + + Scope scope = new Scope(dataMap, entity, aliasTypes); + collectParameterTypes(scope, query.getWhere(), types); + collectParameterTypes(scope, query.getHaving(), types); + } + + private void collectParameterTypes(Scope scope, Expression expression, Map types) { + if (expression == null || expression instanceof EnclosingObjectExp) { + return; + } + + if (expression instanceof SubqueryExp subquery) { + if (subquery.getQuery() != null) { + collectParameterTypes(scope.dataMap(), subquery.getQuery(), types); + } + return; + } + + // a parameter takes the type of a path compared to it + String typeName = OBJECT_TYPE; + for (int i = 0; i < expression.getOperandCount(); i++) { + if (expression.getOperand(i) instanceof ObjPathExp pathExp) { + typeName = scope.aliasTypes().getOrDefault(pathExp.getPath().value(), + pathType(scope.entity(), pathExp)); + } + } - if (operand instanceof ListExp) { - Object[] values = (Object[]) ((ListExp) operand).getOperand(0); - for (Object value : values) { - if (value instanceof ExpressionParameter) { - names.add(((ExpressionParameter) value).getName()); + for (int i = 0; i < expression.getOperandCount(); i++) { + Object operand = expression.getOperand(i); + switch (operand) { + case ExpressionParameter parameter -> { + types.merge(parameter.getName(), typeName, DataMapUtils::moreSpecific); + } + case ListExp list -> { + for (Object value : (Object[]) list.getOperand(0)) { + if (value instanceof ExpressionParameter parameter) { + types.merge(parameter.getName(), typeName, DataMapUtils::moreSpecific); } } } - - if (operand instanceof ExpressionParameter) { - names.add(((ExpressionParameter) operand).getName()); + case Expression child -> collectParameterTypes(scope, child, types); + case null, default -> { } - } + } + } - for (String name : names) { - types.put(Util.underscoredToJava(name, false), typeName); - } + // the type of the attribute or relationship at the end of a path, or Object if the path can't be resolved. An + // invalid path is a problem of the query, reported by the validator, and not of the type inference + private static String pathType(ObjEntity entity, ObjPathExp pathExp) { + if (entity == null) { + return OBJECT_TYPE; + } - return types; + List components; + try { + components = entity.resolvePath(pathExp.getExpandedPath()); + } catch (ExpressionException e) { + return OBJECT_TYPE; } - return Collections.emptyMap(); + + return switch (components.getLast()) { + case ObjAttribute attribute -> attribute.getType(); + case ObjRelationship relationship -> relationship.getTargetEntity().getClassName(); + default -> OBJECT_TYPE; + }; + } + + // the root entity of a query (null if unknown) and the types of its column aliases + private record Scope(DataMap dataMap, ObjEntity entity, Map aliasTypes) { + } + + private static String moreSpecific(String type1, String type2) { + return OBJECT_TYPE.equals(type1) ? type2 : type1; } } diff --git a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm index 68b7160692..308a406076 100644 --- a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm +++ b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm @@ -40,8 +40,8 @@ ${importUtils.addType('org.apache.cayenne.ObjectContext')}## ${importUtils.addType('java.util.List')}## ${importUtils.addType('org.apache.cayenne.query.MappedSelect')}## #foreach( $selectQuery in ${object.SelectQueries}) -${importUtils.addType(${selectQuery.Root.ClassName})}## #if(${dataMapUtils.isValidParameterNames($selectQuery)}) +${importUtils.addType(${dataMapUtils.getResultType($selectQuery)})}## #foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) ${importUtils.addType(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})}## #end## @@ -82,7 +82,7 @@ public class ${subClassName} { #foreach( $selectQuery in ${object.SelectQueries}) #if(${dataMapUtils.isValidParameterNames($selectQuery)}) - #set($rootClass = ${stringUtils.stripPackageName($selectQuery.Root.ClassName)}) + #set($rootClass = ${stringUtils.stripPackageName(${dataMapUtils.getResultType($selectQuery)})}) public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}), ${stringUtils.stripPackageName(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})} ${parameter}#end) { MappedSelect<${rootClass}> query = MappedSelect.query(${stringUtils.capitalizedAsConstant(${selectQuery.Name})}_QUERYNAME, ${rootClass}.class); #foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) diff --git a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm index fcb576b3ba..a6e3e65ba2 100644 --- a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm +++ b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm @@ -39,8 +39,8 @@ ${importUtils.addType('org.apache.cayenne.ObjectContext')}## ${importUtils.addType('java.util.List')}## ${importUtils.addType('org.apache.cayenne.query.MappedSelect')}## #foreach( $selectQuery in ${object.SelectQueries}) -${importUtils.addType(${selectQuery.Root.ClassName})}## #if(${dataMapUtils.isValidParameterNames($selectQuery)}) +${importUtils.addType(${dataMapUtils.getResultType($selectQuery)})}## #foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) ${importUtils.addType(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})}## #end## @@ -71,7 +71,7 @@ public class ${superClassName} { #foreach( $selectQuery in ${object.SelectQueries}) #if(${dataMapUtils.isValidParameterNames($selectQuery)}) - #set($rootClass = ${stringUtils.stripPackageName($selectQuery.Root.ClassName)}) + #set($rootClass = ${stringUtils.stripPackageName(${dataMapUtils.getResultType($selectQuery)})}) public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}), ${stringUtils.stripPackageName(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})} ${parameter}#end) { MappedSelect<${rootClass}> query = MappedSelect.query(${stringUtils.capitalizedAsConstant(${selectQuery.Name})}_QUERYNAME, ${rootClass}.class); #foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java index f72a4b019f..1de1364273 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java @@ -19,8 +19,6 @@ package org.apache.cayenne.gen; -import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.exp.ExpressionFactory; import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.DbAttribute; import org.apache.cayenne.map.DbEntity; @@ -45,6 +43,7 @@ import java.sql.Types; import java.util.ArrayList; import java.util.Collection; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -74,30 +73,43 @@ public void setUp() { @Test public void selectQuery() throws Exception { dataMap.setName("SelectQuery"); + createTestEntity(); - String param = "param"; - String qualifierString = "name = $" + param; + execute(new DataMapArtifact(dataMap, List.of(selectQuery("select", "from Test where name = $param")))); + } + + @Test + public void selectQueryResultTypes() throws Exception { + dataMap.setName("SelectQueryResultTypes"); + createTestEntity(); + + execute(new DataMapArtifact(dataMap, List.of( + selectQuery("names", "select name from Test where name like $name"), + selectQuery("counts", "select name, count(*) from Test"), + selectQuery("rows", "from db:TEST where testKey = $key")))); + } + + private void createTestEntity() { + DbEntity dbEntity = new DbEntity("TEST"); + dbEntity.addAttribute(new DbAttribute("testKey")); + dataMap.addDbEntity(dbEntity); - DbEntity dbEntity = new DbEntity(); ObjAttribute attribute = new ObjAttribute("name"); attribute.setDbAttributePath("testKey"); attribute.setType("java.lang.String"); + objEntity.setName("Test"); objEntity.addAttribute(attribute); objEntity.setDbEntity(dbEntity); objEntity.setClassName("Test"); + dataMap.addObjEntity(objEntity); + } - SelectQueryDescriptor selectQueryDescriptor = new SelectQueryDescriptor(); - Expression exp = ExpressionFactory.exp(qualifierString); - selectQueryDescriptor.setQualifier(exp); - selectQueryDescriptor.setName("select"); - selectQueryDescriptor.setRoot(objEntity); - - Collection descriptors = new ArrayList<>(); - descriptors.add(selectQueryDescriptor); - - DataMapArtifact dataMapArtifact = new DataMapArtifact(dataMap, descriptors); - - execute(dataMapArtifact); + private SelectQueryDescriptor selectQuery(String name, String ql) { + SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); + descriptor.setName(name); + descriptor.setQl(ql); + dataMap.addQueryDescriptor(descriptor); + return descriptor; } @Test diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java index 4f3479df29..5bc0b5cf8f 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java @@ -19,99 +19,132 @@ package org.apache.cayenne.gen; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.exp.ExpressionFactory; +import java.util.List; + +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.DbAttribute; import org.apache.cayenne.map.DbEntity; import org.apache.cayenne.map.ObjAttribute; import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.ObjRelationship; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class DataMapUtilsTest { - protected DataMapUtils dataMapUtils = null; - protected ObjEntity objEntity = null; + private DataMapUtils dataMapUtils; + private DataMap dataMap; @BeforeEach public void setUp() { dataMapUtils = new DataMapUtils(); - objEntity = new ObjEntity(); - } - - @AfterEach - public void tearDown() { - dataMapUtils = null; - objEntity = null; + dataMap = new DataMap("map"); + + DbEntity dbEntity = new DbEntity("ARTIST"); + dbEntity.addAttribute(new DbAttribute("NAME")); + dataMap.addDbEntity(dbEntity); + + ObjEntity artist = new ObjEntity("Artist"); + artist.setClassName("org.example.Artist"); + artist.setDbEntity(dbEntity); + artist.addAttribute(new ObjAttribute("name", "java.lang.String", artist)); + artist.addAttribute(new ObjAttribute("born", "java.time.LocalDate", artist)); + dataMap.addObjEntity(artist); + + ObjEntity painting = new ObjEntity("Painting"); + painting.setClassName("org.example.Painting"); + painting.addAttribute(new ObjAttribute("price", "java.math.BigDecimal", painting)); + dataMap.addObjEntity(painting); + + ObjRelationship toArtist = new ObjRelationship("artist"); + toArtist.setTargetEntityName("Artist"); + painting.addRelationship(toArtist); } @Test - public void getParameterNamesWithFilledQueriesMap() { - - String param = "param"; - String qualifierString = "name = $" + param; - - SelectQueryDescriptor selectQueryDescriptor = new SelectQueryDescriptor(); - - Set result = new LinkedHashSet<>(); - assertEquals(result, dataMapUtils.getParameterNames(selectQueryDescriptor)); + public void getParameterNames() { + SelectQueryDescriptor query = query(""" + from Artist where name like $name and born in ($from_date, $to_date) and name != $name"""); - Expression exp = ExpressionFactory.exp(qualifierString); - selectQueryDescriptor.setQualifier(exp); - selectQueryDescriptor.setName("name"); - - Map map = new HashMap<>(); - map.put(param, "java.lang.String"); - - dataMapUtils.queriesMap.put("name", map); - Collection collection = dataMapUtils.getParameterNames(selectQueryDescriptor); - - result.add(param); - - assertEquals(collection, result); + assertEquals(List.of("name", "fromDate", "toDate"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertTrue(dataMapUtils.hasParameters(query)); + assertFalse(dataMapUtils.hasParameters(query("from Artist"))); } @Test - public void getParameterNamesWithEmptyQueriesMap() { - - DbEntity dbEntity = new DbEntity("test"); - ObjAttribute attribute = new ObjAttribute("name"); - attribute.setDbAttributePath("testKey"); - attribute.setType("java.lang.String"); - objEntity.addAttribute(attribute); - objEntity.setName("test"); - objEntity.setDbEntity(dbEntity); - - String param = "param"; - String qualifierString = "name = $" + param; - - SelectQueryDescriptor selectQueryDescriptor = new SelectQueryDescriptor(); - Expression exp = ExpressionFactory.exp(qualifierString); - selectQueryDescriptor.setQualifier(exp); - selectQueryDescriptor.setName("name"); - selectQueryDescriptor.setRoot(objEntity); + public void getParameterType() { + SelectQueryDescriptor query = query(""" + from Painting where price > $price and artist = $artist and $flag = true + and exists (from Artist where born < $born)"""); + + assertEquals("java.math.BigDecimal", dataMapUtils.getParameterType(query, "price")); + assertEquals("org.example.Artist", dataMapUtils.getParameterType(query, "artist")); + assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "flag")); + assertEquals("java.time.LocalDate", dataMapUtils.getParameterType(query, "born")); + } - Collection collection = dataMapUtils.getParameterNames(selectQueryDescriptor); + @Test + public void getParameterTypeOfAlias() { + SelectQueryDescriptor query = query(""" + select name as n, count(*) as c from Artist having c > $min and n like $pattern"""); + + assertTrue(dataMapUtils.isValidParameterNames(query)); + assertEquals(List.of("min", "pattern"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "min")); + assertEquals("java.lang.String", dataMapUtils.getParameterType(query, "pattern")); + } - Map> queriesMap = new HashMap<>(); - Map map = new HashMap<>(); - map.put(param, "java.lang.String"); - queriesMap.put("name", map); + @Test + public void getParameterTypeOfInvalidPath() { + // an invalid path is reported by the validator, the parameter helpers never fail on it + SelectQueryDescriptor query = query("from Artist where nme = $name and born = $born"); + + assertFalse(dataMapUtils.isValidParameterNames(query)); + assertEquals(List.of("name", "born"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertTrue(dataMapUtils.hasParameters(query)); + assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "name")); + assertEquals("java.time.LocalDate", dataMapUtils.getParameterType(query, "born")); + } - assertEquals(dataMapUtils.queriesMap, queriesMap); + @Test + public void isValidParameterNames() { + assertTrue(dataMapUtils.isValidParameterNames(query("from Artist where name = $name order by born"))); + assertTrue(dataMapUtils.isValidParameterNames(query("from db:ARTIST where NAME = $name"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from Artist where"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from Gallery"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from Artist where nme = $name"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from Artist order by nme"))); + + // the paths that are not compared to a parameter are checked too + assertFalse(dataMapUtils.isValidParameterNames(query("from Artist where nme = 'x'"))); + assertFalse(dataMapUtils.isValidParameterNames(query("select nme from Artist"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from db:ARTIST where NME = $name"))); + assertFalse(dataMapUtils.isValidParameterNames(query("from Artist prefetch galleries"))); + } - Set result = new LinkedHashSet<>(); - result.add(param); + @Test + public void getResultType() { + assertEquals("org.example.Artist", dataMapUtils.getResultType(query("from Artist"))); + assertEquals("org.apache.cayenne.DataRow", dataMapUtils.getResultType(query("from db:ARTIST"))); + assertEquals("java.lang.Object", dataMapUtils.getResultType(query("select name from Artist"))); + assertEquals("java.lang.Object[]", dataMapUtils.getResultType(query("select name, born from Artist"))); + + SelectQueryDescriptor dataRows = query("from Artist"); + dataRows.setFetchingDataRows(true); + assertEquals("org.apache.cayenne.DataRow", dataMapUtils.getResultType(dataRows)); + } - assertEquals(collection, result); + private SelectQueryDescriptor query(String ql) { + SelectQueryDescriptor query = new SelectQueryDescriptor(); + query.setName("q"); + query.setQl(ql); + dataMap.removeQueryDescriptor("q"); + dataMap.addQueryDescriptor(query); + return query; } } diff --git a/cayenne-cgen/src/test/resources/templateTest/_SelectQueryResultTypes.java b/cayenne-cgen/src/test/resources/templateTest/_SelectQueryResultTypes.java new file mode 100644 index 0000000000..5a7427573e --- /dev/null +++ b/cayenne-cgen/src/test/resources/templateTest/_SelectQueryResultTypes.java @@ -0,0 +1,18 @@ +package test; + +import test.auto._SelectQueryResultTypes; + +public class SelectQueryResultTypes extends _SelectQueryResultTypes { + + private static SelectQueryResultTypes instance; + + private SelectQueryResultTypes() {} + + public static SelectQueryResultTypes getInstance() { + if(instance == null) { + instance = new SelectQueryResultTypes(); + } + + return instance; + } +} diff --git a/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java b/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java new file mode 100644 index 0000000000..8a223ff253 --- /dev/null +++ b/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java @@ -0,0 +1,40 @@ +package test.auto; + +import java.util.List; + +import org.apache.cayenne.DataRow; +import org.apache.cayenne.ObjectContext; +import org.apache.cayenne.query.MappedSelect; + +/** + * This class was generated by Cayenne. + * It is probably a good idea to avoid changing this class manually, + * since it may be overwritten next time code is regenerated. + * If you need to make any customizations, please use subclass. + */ +public class _SelectQueryResultTypes { + + public static final String NAMES_QUERYNAME = "names"; + + public static final String COUNTS_QUERYNAME = "counts"; + + public static final String ROWS_QUERYNAME = "rows"; + + public List performNames(ObjectContext context, String name) { + MappedSelect query = MappedSelect.query(NAMES_QUERYNAME, Object.class); + query.param("name", name); + return query.select(context); + } + + public List performCounts(ObjectContext context) { + MappedSelect query = MappedSelect.query(COUNTS_QUERYNAME, Object[].class); + return query.select(context); + } + + public List performRows(ObjectContext context, Object key) { + MappedSelect query = MappedSelect.query(ROWS_QUERYNAME, DataRow.class); + query.param("key", key); + return query.select(context); + } + +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/text/CMUndoableSyntaxTextArea.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/text/CMUndoableSyntaxTextArea.java new file mode 100644 index 0000000000..ec54e700c1 --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/text/CMUndoableSyntaxTextArea.java @@ -0,0 +1,79 @@ +/***************************************************************** + * 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.modeler.toolkit.text; + +import javax.swing.InputMap; +import javax.swing.KeyStroke; +import javax.swing.event.UndoableEditEvent; +import javax.swing.event.UndoableEditListener; + +import org.apache.cayenne.modeler.undo.CayenneUndoManager; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rtextarea.RTextAreaEditorKit; +import org.fife.ui.rtextarea.RUndoManager; + +/** + * A syntax-highlighting text area that records its edits in the Modeler {@link CayenneUndoManager}, the same as + * {@link CMUndoableTextPane}, instead of its own undo history. + */ +public class CMUndoableSyntaxTextArea extends RSyntaxTextArea { + + private final UndoableEditListener undoListener; + + public CMUndoableSyntaxTextArea(CayenneUndoManager undoManager) { + this.undoListener = new TextComponentUndoListener(this, undoManager); + getDocument().addUndoableEditListener(undoListener); + + // let the undo and redo keys reach the Modeler menu actions, as the own undo history of the text area is off + InputMap inputMap = getInputMap(); + KeyStroke[] keys = inputMap.allKeys(); + if (keys != null) { + for (KeyStroke key : keys) { + Object action = inputMap.get(key); + if (RTextAreaEditorKit.rtaUndoAction.equals(action) + || RTextAreaEditorKit.rtaRedoAction.equals(action)) { + inputMap.put(key, "none"); + } + } + } + } + + @Override + protected RUndoManager createUndoManager() { + return new RUndoManager(this) { + @Override + public void undoableEditHappened(UndoableEditEvent e) { + // the edits go to the Modeler undo manager + } + }; + } + + /** + * Replaces the text without recording an undoable edit. + */ + @Override + public void setText(String text) { + getDocument().removeUndoableEditListener(undoListener); + try { + super.setText(text); + } finally { + getDocument().addUndoableEditListener(undoListener); + } + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModel.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModel.java index c4ae853ff4..262f22e471 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModel.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModel.java @@ -24,6 +24,7 @@ import org.apache.cayenne.map.Entity; import org.apache.cayenne.map.Relationship; import org.apache.cayenne.modeler.project.ProjectComparators; +import org.apache.cayenne.modeler.toolkit.Renderers; import javax.swing.event.TreeModelListener; import javax.swing.tree.TreeModel; @@ -171,4 +172,23 @@ public void setFilter(EntityTreeFilter filter) { public EntityTreeFilter getFilter() { return filter; } + + /** + * Returns a dot-separated path of the attributes and relationships of a tree path, relative to the root entity, + * or null if the tree path is the root itself. + */ + public static String toPath(TreePath treePath) { + Object[] path = treePath.getPath(); + + // the first item is the root entity, that is not a part of the path + if (path.length < 2) { + return null; + } + + StringBuilder buffer = new StringBuilder(Renderers.asString(path[1])); + for (int i = 2; i < path.length; i++) { + buffer.append('.').append(Renderers.asString(path[i])); + } + return buffer.toString(); + } } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/EditorPanelView.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/EditorPanelView.java index d7eaca3ced..4c7d972330 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/EditorPanelView.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/EditorPanelView.java @@ -30,7 +30,7 @@ import org.apache.cayenne.modeler.ui.project.editor.objentity.ObjEntityView; import org.apache.cayenne.modeler.ui.project.editor.procedure.ProcedureQueryTabbedView; import org.apache.cayenne.modeler.ui.project.editor.procedure.ProcedureTabbedView; -import org.apache.cayenne.modeler.ui.project.editor.query.selectquery.SelectQueryTabbedView; +import org.apache.cayenne.modeler.ui.project.editor.query.selectquery.ObjectSelectTabbedView; import org.apache.cayenne.modeler.ui.project.editor.query.sqltemplate.SQLTemplateTabbedView; import javax.swing.*; @@ -63,6 +63,7 @@ public class EditorPanelView extends ProjectPanel { private final ProjectEditorView projectEditorView; private final DataMapView dataMapView; private final ProcedureTabbedView procedureView; + private final ObjectSelectTabbedView selectQueryView; private final SQLTemplateTabbedView sqlTemplateView; public EditorPanelView(ProjectSession session) { @@ -72,6 +73,7 @@ public EditorPanelView(ProjectSession session) { this.projectEditorView = new ProjectEditorView(session); this.dataMapView = new DataMapView(session); this.procedureView = new ProcedureTabbedView(session); + this.selectQueryView = new ObjectSelectTabbedView(session); this.sqlTemplateView = new SQLTemplateTabbedView(session); this.embeddableView = new EmbeddableView(session); this.objDetailView = new ObjEntityView(session); @@ -91,7 +93,7 @@ private void initLayout() { add(projectEditorView, PROJECT_VIEW); add(dataMapView, DATA_MAP_VIEW); add(procedureView, PROCEDURE_VIEW); - add(new SelectQueryTabbedView(session), SELECT_QUERY_VIEW); + add(selectQueryView, SELECT_QUERY_VIEW); add(sqlTemplateView, SQL_TEMPLATE_VIEW); add(new ProcedureQueryTabbedView(session), PROCEDURE_QUERY_VIEW); add(embeddableView, EMBEDDABLE_VIEW); @@ -110,6 +112,10 @@ private void initBindings() { session.addEmbeddableDisplayListener(e -> detailLayout.show(this, e.getEmbeddable() == null ? EMPTY_VIEW : EMBEDDABLE_VIEW)); } + public ObjectSelectTabbedView getSelectQueryView() { + return selectQueryView; + } + public SQLTemplateTabbedView getSqlTemplateView() { return sqlTemplateView; } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/ObjectQueryPropertiesPanel.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/ObjectQueryPropertiesPanel.java index 5c921ba2e8..6eade6a9a5 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/ObjectQueryPropertiesPanel.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/ObjectQueryPropertiesPanel.java @@ -24,6 +24,7 @@ import javax.swing.JCheckBox; import org.apache.cayenne.modeler.toolkit.checkbox.CMCheckBox; +import org.apache.cayenne.modeler.toolkit.text.CMUndoableTextField; import org.apache.cayenne.modeler.project.ProjectSession; import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.map.SelectQueryDescriptor; @@ -34,12 +35,14 @@ import com.jgoodies.forms.layout.FormLayout; /** - * A panel that supports editing the properties of a query based on ObjEntity. - * + * A panel that supports editing the properties of an ObjectSelect query that are not a part of its query String. */ public class ObjectQueryPropertiesPanel extends SelectPropertiesPanel { + private static final String DATA_ROWS_TOOLTIP = "A column query or a query rooted in a DbEntity fetches no objects"; + protected JCheckBox dataRows; + protected CMUndoableTextField statementFetchSize; public ObjectQueryPropertiesPanel(ProjectSession session) { super(session, SelectQueryDescriptor.class); @@ -51,11 +54,14 @@ protected void initView() { dataRows = new CMCheckBox(session.app().getUndoManager()); + statementFetchSize = new CMUndoableTextField(session.app().getUndoManager(), 7); + statementFetchSize.addCommitListener(this::setStatementFetchSize); + // assemble CellConstraints cc = new CellConstraints(); FormLayout layout = new FormLayout( EditorForm.LABEL_COLUMN + ", $lcgap, left:max(50dlu;pref), fill:max(150dlu;pref)", - "p, $rgap, p, $rgap, p, $rgap, p, $rgap, p, $rgap, p"); + "p, $rgap, p, $rgap, p, $rgap, p, $rgap, p"); PanelBuilder builder = new PanelBuilder(layout); builder.setBorder(EditorForm.sectionBorder()); builder.addLabel("Result Caching:", cc.xy(1, 1)); @@ -64,12 +70,10 @@ protected void initView() { builder.add(cacheGroups, cc.xywh(3, 3, 2, 1)); builder.addLabel("Fetch Data Rows:", cc.xy(1, 5)); builder.add(dataRows, cc.xy(3, 5)); - builder.addLabel("Fetch Offset, Rows:", cc.xy(1, 7)); - builder.add(fetchOffset, cc.xy(3, 7)); - builder.addLabel("Fetch Limit, Rows:", cc.xy(1, 9)); - builder.add(fetchLimit, cc.xy(3, 9)); - builder.addLabel("Page Size:", cc.xy(1, 11)); - builder.add(pageSize, cc.xy(3, 11)); + builder.addLabel("Page Size:", cc.xy(1, 7)); + builder.add(pageSize, cc.xy(3, 7)); + builder.addLabel("Statement Fetch Size:", cc.xy(1, 9)); + builder.add(statementFetchSize, cc.xy(3, 9)); this.setLayout(new BorderLayout()); this.add(builder.getPanel(), BorderLayout.CENTER); @@ -82,14 +86,9 @@ protected void initController() { QueryDescriptor::setFetchingDataRows, dataRows.isSelected())); } - @Override - protected void setFetchOffset(int fetchOffset) { - setQueryProperty(SelectQueryDescriptor::getFetchOffset, SelectQueryDescriptor::setFetchOffset, fetchOffset); - } - - @Override - protected void setFetchLimit(int fetchLimit) { - setQueryProperty(SelectQueryDescriptor::getFetchLimit, SelectQueryDescriptor::setFetchLimit, fetchLimit); + void setStatementFetchSize(String string) { + setQueryProperty(QueryDescriptor::getStatementFetchSize, QueryDescriptor::setStatementFetchSize, + intValue(string, "Statement fetch size")); } /** @@ -101,7 +100,16 @@ public void initFromModel(SelectQueryDescriptor query) { super.initFromModel(query); dataRows.setSelected(query.isFetchingDataRows()); - fetchOffset.setText(String.valueOf(query.getFetchOffset())); - fetchLimit.setText(String.valueOf(query.getFetchLimit())); + statementFetchSize.setText(String.valueOf(query.getStatementFetchSize())); + updateDataRowsEnabled(query); + } + + /** + * Enables the "Fetch Data Rows" option only for the queries that fetch objects, which the query String defines. + */ + public void updateDataRowsEnabled(SelectQueryDescriptor query) { + boolean fetchesObjects = !query.isColumnQuery() && !query.isDbQuery(); + dataRows.setEnabled(fetchesObjects); + dataRows.setToolTipText(fetchesObjects ? null : DATA_ROWS_TOOLTIP); } } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/PrefetchModel.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/PrefetchModel.java index 1891fd54bc..e986e97283 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/PrefetchModel.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/PrefetchModel.java @@ -19,7 +19,6 @@ package org.apache.cayenne.modeler.ui.project.editor.query; -import org.apache.cayenne.modeler.ui.project.editor.query.selectquery.SelectQueryPrefetchTab; import org.apache.cayenne.exp.Expression; import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ExpressionFactory; @@ -33,6 +32,11 @@ */ public class PrefetchModel extends AbstractTableModel { + public static final String JOINT_PREFETCH_SEMANTICS = "Joint"; + public static final String DISJOINT_PREFETCH_SEMANTICS = "Disjoint"; + public static final String DISJOINT_BY_ID_PREFETCH_SEMANTICS = "Disjoint by id"; + public static final String UNDEFINED_SEMANTICS = "Undefined semantics"; + private final Map prefetchMap; private final Object root; @@ -124,10 +128,10 @@ public static int getPrefetchType(String semantics) { private static String getPrefetchTypeString(int semantics) { return switch (semantics) { - case 1 -> SelectQueryPrefetchTab.JOINT_PREFETCH_SEMANTICS; - case 2 -> SelectQueryPrefetchTab.DISJOINT_PREFETCH_SEMANTICS; - case 3 -> SelectQueryPrefetchTab.DISJOINT_BY_ID_PREFETCH_SEMANTICS; - default -> SelectQueryPrefetchTab.UNDEFINED_SEMANTICS; + case 1 -> JOINT_PREFETCH_SEMANTICS; + case 2 -> DISJOINT_PREFETCH_SEMANTICS; + case 3 -> DISJOINT_BY_ID_PREFETCH_SEMANTICS; + default -> UNDEFINED_SEMANTICS; }; } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/SelectPropertiesPanel.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/SelectPropertiesPanel.java index 18e663113c..8e7373ac91 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/SelectPropertiesPanel.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/SelectPropertiesPanel.java @@ -191,7 +191,7 @@ protected void setQueryProperty(Function getter, BiConsumer initFromModel()); + new SplitPanePrefs(app.getPrefsManager().uiNode("objectSelect/editorSplit")) + .bind(split, DEFAULT_SPLIT_LOCATION); + } + + private void initFromModel() { + SelectQueryDescriptor query = getQuery(); + if (query == null) { + editor.setQuery(null); + statusLine.update(null, List.of()); + pathBrowser.setEntity(null); + return; + } + + name.setText(query.getName()); + comment.setText(ObjectInfo.getFromMetaData(app.getMetaData(), query, ObjectInfo.COMMENT)); + properties.initFromModel(query); + editor.setQuery(query); + + // the entity may have changed since the tree was built + pathBrowser.refresh(); + } + + // a reaction to each check of the query String by the editor + private void queryChecked(List problems) { + SelectQueryDescriptor query = getQuery(); + statusLine.update(query, problems); + if (query != null) { + // a String that can't be parsed keeps the last tree, so that it doesn't flicker while typing + if (query.isValid()) { + pathBrowser.setEntity(query.getRoot() instanceof Entity entity ? entity : null); + } + properties.updateDataRowsEnabled(query); + } + } + + private void setQueryComment(String text) { + QueryDescriptor query = getQuery(); + if (query == null) { + return; + } + ObjectInfo.putToMetaData(app.getMetaData(), query, ObjectInfo.COMMENT, text); + session.fireQueryEvent(QueryEvent.ofChange(this, query)); + } + + @Override + protected SelectQueryDescriptor getQuery() { + return session.getSelectedQuery() instanceof SelectQueryDescriptor query ? query : null; + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditor.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditor.java new file mode 100644 index 0000000000..cefd92b4b5 --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditor.java @@ -0,0 +1,149 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import javax.swing.BorderFactory; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.modeler.event.model.QueryEvent; +import org.apache.cayenne.modeler.project.ProjectSession; +import org.apache.cayenne.modeler.toolkit.ProjectPanel; +import org.apache.cayenne.modeler.toolkit.text.CMUndoableSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; +import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; +import org.fife.ui.rtextarea.RTextScrollPane; + +/** + * A text editor of the query String of an ObjectSelect query, with the syntax highlighted, and the problems of the + * query marked. Each edit is stored in the query as is, even if the String can't be parsed. + */ +class QLEditor extends ProjectPanel { + + private static final int PARSER_DELAY_MS = 300; + + static { + AbstractTokenMakerFactory factory = (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); + factory.putMapping(QLTokenMaker.SYNTAX_STYLE, QLTokenMaker.class.getName()); + } + + private final CMUndoableSyntaxTextArea textArea; + private final QLEditorParser parser; + + private SelectQueryDescriptor query; + + // true while the text is loaded from the model, so that it isn't written back + private boolean loading; + + /** + * @param problemsListener receives the problems of the query after each check, empty if there are none + */ + QLEditor(ProjectSession session, Consumer> problemsListener) { + super(session); + this.textArea = new CMUndoableSyntaxTextArea(app.getUndoManager()); + this.parser = new QLEditorParser(() -> query, problemsListener); + initLayout(); + initBindings(); + } + + private void initLayout() { + textArea.setSyntaxEditingStyle(QLTokenMaker.SYNTAX_STYLE); + textArea.setRows(8); + textArea.setHighlightCurrentLine(false); + textArea.setCodeFoldingEnabled(false); + textArea.setAntiAliasingEnabled(true); + textArea.setParserDelay(PARSER_DELAY_MS); + textArea.addParser(parser); + + setLayout(new BorderLayout()); + RTextScrollPane scrollPane = new RTextScrollPane(textArea, true); + scrollPane.setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY)); + add(scrollPane, BorderLayout.CENTER); + } + + private void initBindings() { + textArea.getDocument().addDocumentListener(new TextChangeListener()); + } + + /** + * Shows the query String of the query, or clears the editor if the query is null. + */ + void setQuery(SelectQueryDescriptor query) { + this.query = query; + + loading = true; + try { + textArea.setText(query != null && query.getQl() != null ? query.getQl() : ""); + textArea.setCaretPosition(0); + } finally { + loading = false; + } + + // check right away, not after the delay that is meant for typing + textArea.forceReparsing(parser); + } + + /** + * Inserts the text at the caret, in place of the selection. + */ + void insert(String text) { + textArea.replaceSelection(text); + textArea.requestFocusInWindow(); + } + + private void textChanged() { + if (loading || query == null) { + return; + } + + String text = textArea.getText(); + String ql = text.isBlank() ? null : text; + if (Objects.equals(ql, query.getQl())) { + return; + } + + query.setQl(ql); + session.fireQueryEvent(QueryEvent.ofChange(this, query)); + } + + private final class TextChangeListener implements DocumentListener { + + @Override + public void insertUpdate(DocumentEvent e) { + textChanged(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + textChanged(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + // style changes only + } + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParser.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParser.java new file mode 100644 index 0000000000..e1c4a26442 --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParser.java @@ -0,0 +1,175 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.text.Element; + +import org.apache.cayenne.exp.ExpressionException; +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.projecttools.validation.SelectQueryPaths; +import org.apache.cayenne.ql.ParseException; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.parser.AbstractParser; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParseResult; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParserNotice; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; + +/** + * Checks the query String in the editor: marks a syntax error, or the entity names and paths that can't be resolved + * against the mapping, and reports the found problems to a listener. Runs on the event dispatch thread, after a delay + * since the last edit. + */ +class QLEditorParser extends AbstractParser { + + private static final Pattern POSITION = Pattern.compile("line (\\d+), column (\\d+)"); + + private final Supplier query; + private final Consumer> problemsListener; + + /** + * @param query the query edited, that holds the text of the editor + * @param problemsListener receives the messages of the found problems, empty if there are none + */ + QLEditorParser(Supplier query, Consumer> problemsListener) { + this.query = query; + this.problemsListener = problemsListener; + } + + @Override + public ParseResult parse(RSyntaxDocument doc, String style) { + DefaultParseResult result = new DefaultParseResult(this); + Element root = doc.getDefaultRootElement(); + result.setParsedLines(0, root.getElementCount() - 1); + + SelectQueryDescriptor descriptor = query.get(); + if (descriptor == null) { + return result; + } + + List problems = new ArrayList<>(); + if (descriptor.getQl() == null || descriptor.getQl().isBlank()) { + problems.add("The query is empty"); + } else if (!descriptor.isValid()) { + try { + descriptor.getParsedQuery(); + } catch (ExpressionException e) { + String message = firstLine(e.getUnlabeledMessage()); + int[] range = errorRange(e, doc); + result.addNotice(new DefaultParserNotice(this, message, lineOf(root, range[0]), range[0], range[1])); + problems.add(message); + } + } else { + for (SelectQueryPaths.Problem problem : SelectQueryPaths.check(descriptor)) { + for (int[] range : occurrences(doc, problem.name())) { + result.addNotice(new DefaultParserNotice(this, problem.message(), lineOf(root, range[0]), + range[0], range[1])); + } + problems.add(problem.message()); + } + } + + problemsListener.accept(problems); + return result; + } + + /** + * Returns the offset and the length of the text that caused a parse error. + */ + static int[] errorRange(ExpressionException error, RSyntaxDocument doc) { + int length = doc.getLength(); + if (length == 0) { + return new int[]{0, 0}; + } + + int offset; + int size = 1; + if (error.getCause() instanceof ParseException parseError + && parseError.currentToken != null + && parseError.currentToken.next != null) { + // the token that the parser didn't expect + int line = parseError.currentToken.next.beginLine; + int beginColumn = parseError.currentToken.next.beginColumn; + int endColumn = parseError.currentToken.next.endColumn; + offset = offsetOf(doc, line, beginColumn); + if (parseError.currentToken.next.endLine == line && endColumn >= beginColumn) { + size = endColumn - beginColumn + 1; + } + } else { + Matcher matcher = POSITION.matcher(String.valueOf(error.getMessage())); + offset = matcher.find() + ? offsetOf(doc, Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))) + : length - 1; + } + + // an error at the end of the text is shown on its last char + offset = Math.max(0, Math.min(offset, length - 1)); + size = Math.max(1, Math.min(size, length - offset)); + return new int[]{offset, size}; + } + + // the line and the column are 1-based + private static int offsetOf(RSyntaxDocument doc, int line, int column) { + Element root = doc.getDefaultRootElement(); + int lineIndex = Math.max(0, Math.min(line - 1, root.getElementCount() - 1)); + return root.getElement(lineIndex).getStartOffset() + Math.max(0, column - 1); + } + + private static int lineOf(Element root, int offset) { + return root.getElementIndex(offset); + } + + /** + * Returns the offsets and the lengths of the occurrences of a name in the text, either a whole identifier token, or + * a prefixed one like "db:NAME". + */ + static List occurrences(RSyntaxDocument doc, String name) { + List ranges = new ArrayList<>(); + Element root = doc.getDefaultRootElement(); + for (int line = 0; line < root.getElementCount(); line++) { + Token previous = null; + for (Token t = doc.getTokenListForLine(line); t != null && t.isPaintable(); t = t.getNextToken()) { + if (t.getType() == Token.IDENTIFIER && name.equals(t.getLexeme())) { + ranges.add(new int[]{t.getOffset(), t.length()}); + } else if (t.getType() == Token.IDENTIFIER && previous != null + && previous.getType() == Token.DATA_TYPE + && name.equals(previous.getLexeme() + t.getLexeme())) { + ranges.add(new int[]{previous.getOffset(), previous.length() + t.length()}); + } + previous = t; + } + } + return ranges; + } + + private static String firstLine(String message) { + if (message == null) { + return "Invalid query"; + } + int eol = message.indexOf('\n'); + return (eol >= 0 ? message.substring(0, eol) : message).trim(); + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLPathBrowser.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLPathBrowser.java new file mode 100644 index 0000000000..bc8f35484a --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLPathBrowser.java @@ -0,0 +1,185 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Consumer; + +import javax.swing.AbstractAction; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTree; +import javax.swing.KeyStroke; +import javax.swing.SwingConstants; +import javax.swing.UIManager; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +import org.apache.cayenne.map.Entity; +import org.apache.cayenne.modeler.toolkit.Renderers; +import org.apache.cayenne.modeler.toolkit.icon.IconFactory; +import org.apache.cayenne.modeler.toolkit.tree.EntityTreeModel; + +/** + * A tree of the attributes and relationships of the root entity of a query, that inserts the path of a node into the + * query String on a double click. + */ +class QLPathBrowser extends JPanel { + + private static final String TREE_CARD = "tree"; + private static final String PLACEHOLDER_CARD = "placeholder"; + + private final JLabel title; + private final JTree tree; + private final JLabel hint; + private final JPanel cards; + private final CardLayout cardLayout; + private final Consumer pathConsumer; + + private Entity entity; + + /** + * @param pathConsumer receives the path of the node chosen by the user + */ + QLPathBrowser(Consumer pathConsumer) { + this.pathConsumer = pathConsumer; + this.title = new JLabel(); + this.tree = new JTree(new DefaultTreeModel(null)); + this.hint = new JLabel("Double-click inserts a path"); + this.cardLayout = new CardLayout(); + this.cards = new JPanel(cardLayout); + initLayout(); + initBindings(); + } + + private void initLayout() { + tree.setRootVisible(false); + tree.setShowsRootHandles(true); + + // a double click inserts a path, the nodes are expanded with their handles + tree.setToggleClickCount(0); + tree.setCellRenderer(new PathNodeRenderer()); + + title.setBorder(BorderFactory.createEmptyBorder(0, 2, 3, 0)); + hint.setBorder(BorderFactory.createEmptyBorder(3, 2, 0, 0)); + hint.setForeground(UIManager.getColor("Label.disabledForeground")); + + JLabel placeholder = new JLabel("Write \"from <Entity>\" to browse its paths", + SwingConstants.CENTER); + placeholder.setForeground(UIManager.getColor("Label.disabledForeground")); + + JScrollPane treeScrollPane = new JScrollPane(tree); + treeScrollPane.setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY)); + cards.add(treeScrollPane, TREE_CARD); + cards.add(placeholder, PLACEHOLDER_CARD); + + setLayout(new BorderLayout()); + add(title, BorderLayout.NORTH); + add(cards, BorderLayout.CENTER); + add(hint, BorderLayout.SOUTH); + + setEntity(null); + } + + private void initBindings() { + tree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + TreePath path = tree.getPathForLocation(e.getX(), e.getY()); + if (path != null) { + choosePath(path); + } + } + } + }); + + tree.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "insertPath"); + tree.getActionMap().put("insertPath", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + TreePath path = tree.getSelectionPath(); + if (path != null) { + choosePath(path); + } + } + }); + } + + /** + * Shows the paths of the entity, or a placeholder if the entity is null. The tree is rebuilt only if the entity + * is different from the one shown. + */ + void setEntity(Entity entity) { + if (entity != null && entity == this.entity) { + return; + } + + this.entity = entity; + if (entity != null) { + title.setText(entity.getName()); + tree.setModel(new EntityTreeModel(entity)); + cardLayout.show(cards, TREE_CARD); + } else { + title.setText(" "); + tree.setModel(new DefaultTreeModel(null)); + cardLayout.show(cards, PLACEHOLDER_CARD); + } + } + + /** + * Rebuilds the tree of the entity shown, to pick up the changes of its attributes and relationships. + */ + void refresh() { + if (entity != null) { + tree.setModel(new EntityTreeModel(entity)); + } + } + + private void choosePath(TreePath path) { + String dotPath = EntityTreeModel.toPath(path); + if (dotPath != null) { + pathConsumer.accept(dotPath); + } + } + + // the nodes of the tree are the attributes and relationships themselves + private static final class PathNodeRenderer extends DefaultTreeCellRenderer { + + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded, + boolean leaf, int row, boolean hasFocus) { + super.getTreeCellRendererComponent(tree, Renderers.asString(value), selected, expanded, leaf, row, + hasFocus); + setIcon(IconFactory.iconForObject(value)); + return this; + } + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java new file mode 100644 index 0000000000..ceaacc119b --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java @@ -0,0 +1,97 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.awt.Color; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.UIManager; + +import org.apache.cayenne.map.DbEntity; +import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.query.FluentSelect; + +/** + * A line under the query editor that shows what the query returns and its parameters, or the first problem of the + * query. + */ +class QLStatusLine extends JLabel { + + QLStatusLine() { + setBorder(BorderFactory.createEmptyBorder(4, 2, 0, 0)); + } + + /** + * Shows the state of the query after it was checked. + * + * @param problems the problems found in the query, empty if there are none + */ + void update(SelectQueryDescriptor query, List problems) { + if (query == null) { + setText(" "); + setToolTipText(null); + return; + } + + if (!problems.isEmpty()) { + String more = problems.size() > 1 ? " (and " + (problems.size() - 1) + " more)" : ""; + setText("✗ " + problems.getFirst() + more); + setToolTipText(problems.size() > 1 ? "" + String.join("
", problems) + "" : null); + setForeground(color("Actions.Red", Color.RED)); + return; + } + + Set parameters = query.getParameterNames(); + String parametersText = parameters.isEmpty() + ? "none" + : parameters.stream().map(p -> "$" + p).collect(Collectors.joining(", ")); + + setText("✓ Returns: " + resultType(query) + " Parameters: " + parametersText); + setToolTipText(null); + setForeground(UIManager.getColor("Label.foreground")); + } + + /** + * Returns a description of the objects that a valid query returns. + */ + static String resultType(SelectQueryDescriptor query) { + FluentSelect parsed = query.getParsedQuery(); + if (query.isColumnQuery()) { + int columns = parsed.getColumns().size(); + return columns == 1 ? "Object (1 column)" : "Object[] (" + columns + " columns)"; + } + + return switch (query.getRoot()) { + case DbEntity entity -> "DataRow (" + entity.getName() + ")"; + case ObjEntity entity when query.isFetchingDataRows() -> "DataRow (" + entity.getName() + ")"; + case ObjEntity entity -> entity.getName(); + case null, default -> String.valueOf(query.getRoot()); + }; + } + + private static Color color(String key, Color defaultColor) { + Color color = UIManager.getColor(key); + return color != null ? color : defaultColor; + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMaker.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMaker.java new file mode 100644 index 0000000000..9a61708b91 --- /dev/null +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMaker.java @@ -0,0 +1,156 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.util.List; +import java.util.Set; + +import javax.swing.text.Segment; + +import org.apache.cayenne.query.ObjectSelect; +import org.fife.ui.rsyntaxtextarea.AbstractTokenMaker; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.TokenMap; + +/** + * Splits the lines of a Cayenne QL query (see {@link ObjectSelect#parse(String, Object...)}) into + * tokens for highlighting. The clause keywords of the language are contextual, i.e. they are valid property names + * outside the positions of the clauses. This is ignored here, and they are highlighted everywhere. + */ +public class QLTokenMaker extends AbstractTokenMaker { + + /** + * The syntax style key of the language, to register this token maker in RSyntaxTextArea. + */ + public static final String SYNTAX_STYLE = "text/cayenne-ql"; + + static final List KEYWORDS = List.of("select", "distinct", "from", "where", "having", "order", "by", "asc", + "desc", "insensitive", "limit", "offset", "prefetch", "joint", "disjoint", "disjointById", "as", "self", + "enclosing", "all", "any"); + + static final List OPERATORS = List.of("and", "or", "not", "like", "likeIgnoreCase", "in", "between", + "exists"); + + static final List FUNCTIONS = List.of("avg", "min", "max", "sum", "count", "concat", "substring", "trim", + "lower", "upper", "length", "locate", "abs", "sqrt", "mod", "currentDate", "currentTime", + "currentTimestamp", "now", "year", "month", "week", "dayOfYear", "day", "dayOfMonth", "dayOfWeek", "hour", + "minute", "second", "fn", "op", "agg"); + + static final List LITERALS = List.of("null", "NULL", "true", "TRUE", "false", "FALSE"); + + private static final Set PATH_PREFIXES = Set.of("db", "obj", "enum", "dbid"); + + @Override + public TokenMap getWordsToHighlight() { + TokenMap map = new TokenMap(); + KEYWORDS.forEach(w -> map.put(w, Token.RESERVED_WORD)); + OPERATORS.forEach(w -> map.put(w, Token.OPERATOR)); + FUNCTIONS.forEach(w -> map.put(w, Token.FUNCTION)); + LITERALS.forEach(w -> map.put(w, Token.LITERAL_BOOLEAN)); + return map; + } + + @Override + public void addToken(Segment segment, int start, int end, int tokenType, int startOffset) { + // a word is a keyword only if it is a whole token, a path like "order.date" is not + if (tokenType == Token.IDENTIFIER) { + int type = wordsToHighlight.get(segment, start, end); + if (type != -1) { + tokenType = type; + } + } + super.addToken(segment, start, end, tokenType, startOffset); + } + + @Override + public Token getTokenList(Segment text, int initialTokenType, int startOffset) { + resetTokenList(); + + char[] array = text.array; + int end = text.offset + text.count; + + // the offset of a char in the document is "shift" + its index in the array + int shift = startOffset - text.offset; + + int i = text.offset; + while (i < end) { + char c = array[i]; + int start = i; + + if (Character.isWhitespace(c)) { + while (i < end && Character.isWhitespace(array[i])) { + i++; + } + addToken(text, start, i - 1, Token.WHITESPACE, shift + start); + } else if (c == '\'' || c == '"') { + i++; + while (i < end && array[i] != c) { + // an escaped char, the quote included + i += array[i] == '\\' ? 2 : 1; + } + boolean closed = i < end; + i = Math.min(i + 1, end); + addToken(text, start, i - 1, closed ? Token.LITERAL_STRING_DOUBLE_QUOTE : Token.ERROR_STRING_DOUBLE, + shift + start); + } else if (c == '$') { + i++; + while (i < end && isPathChar(array[i])) { + i++; + } + addToken(text, start, i - 1, Token.VARIABLE, shift + start); + } else if (Character.isDigit(c)) { + boolean decimal = false; + while (i < end && (Character.isLetterOrDigit(array[i]) || array[i] == '.')) { + decimal |= array[i] == '.'; + i++; + } + addToken(text, start, i - 1, decimal ? Token.LITERAL_NUMBER_FLOAT : Token.LITERAL_NUMBER_DECIMAL_INT, + shift + start); + } else if (isPathStart(c)) { + while (i < end && isPathChar(array[i])) { + i++; + } + + // "db:", "obj:", "enum:" or "dbid:" prefix of a path + if (i < end && array[i] == ':' && PATH_PREFIXES.contains(new String(array, start, i - start))) { + i++; + addToken(text, start, i - 1, Token.DATA_TYPE, shift + start); + } else { + addToken(text, start, i - 1, Token.IDENTIFIER, shift + start); + } + } else { + i++; + int type = c == '(' || c == ')' || c == ',' ? Token.SEPARATOR : Token.OPERATOR; + addToken(text, start, start, type, shift + start); + } + } + + addNullToken(); + return firstToken; + } + + private static boolean isPathStart(char c) { + return Character.isLetter(c) || c == '_'; + } + + // the chars of a path, including the join "+" and alias "#" markers + private static boolean isPathChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '.' || c == '+' || c == '#'; + } +} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryMainTab.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryMainTab.java deleted file mode 100644 index 699749d174..0000000000 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryMainTab.java +++ /dev/null @@ -1,286 +0,0 @@ -/***************************************************************** - * 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.modeler.ui.project.editor.query.selectquery; - -import com.jgoodies.forms.builder.PanelBuilder; -import com.jgoodies.forms.layout.CellConstraints; -import com.jgoodies.forms.layout.FormLayout; -import org.apache.cayenne.exp.DbPathExp; -import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.exp.ExpressionException; -import org.apache.cayenne.exp.PathExp; -import org.apache.cayenne.map.DataMap; -import org.apache.cayenne.map.Entity; -import org.apache.cayenne.map.ObjEntity; -import org.apache.cayenne.map.QueryDescriptor; -import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.modeler.event.model.QueryEvent; -import org.apache.cayenne.modeler.toolkit.checkbox.CMCheckBox; -import org.apache.cayenne.modeler.toolkit.text.CMUndoableTextField; -import org.apache.cayenne.modeler.project.ProjectSession; -import org.apache.cayenne.modeler.ui.project.editor.EditorForm; -import org.apache.cayenne.modeler.ui.project.editor.ExpressionConvertor; -import org.apache.cayenne.modeler.ui.project.editor.query.BaseQueryMainTab; -import org.apache.cayenne.modeler.ui.project.editor.query.ObjectQueryPropertiesPanel; -import org.apache.cayenne.modeler.project.ProjectComparators; -import org.apache.cayenne.projecttools.extension.info.ObjectInfo; -import java.util.Objects; -import org.apache.cayenne.validation.ValidationException; - -import javax.swing.*; -import java.awt.*; -import java.util.Arrays; - -/** - * A tabbed pane that contains editors for various SelectQuery parts. - * - */ -public class SelectQueryMainTab extends BaseQueryMainTab { - - protected CMUndoableTextField comment; - protected CMUndoableTextField qualifier; - protected JCheckBox distinct; - protected ObjectQueryPropertiesPanel properties; - - public SelectQueryMainTab(ProjectSession session) { - super(session); - - initQueryRoot(); - initView(); - initController(); - } - - private void initView() { - // create widgets - name = new CMUndoableTextField(app.getUndoManager()); - name.addCommitListener(this::setQueryName); - - qualifier = new CMUndoableTextField(app.getUndoManager()); - qualifier.addCommitListener(this::setQueryQualifier); - - comment = new CMUndoableTextField(app.getUndoManager()); - comment.addCommitListener(this::setQueryComment); - - distinct = new CMCheckBox(app.getUndoManager()); - - properties = new ObjectQueryPropertiesPanel(session); - - // assemble - CellConstraints cc = new CellConstraints(); - FormLayout layout = new FormLayout( - EditorForm.LABEL_COLUMN + ", $lcgap, fill:200dlu", - "p, $rgap, p, $rgap, p, $rgap, p"); - PanelBuilder builder = new PanelBuilder(layout); - builder.setBorder(EditorForm.formBorder()); - - builder.addLabel("Name:", cc.xy(1, 1)); - builder.add(name, cc.xy(3, 1)); - builder.addLabel("Root:", cc.xy(1, 3)); - builder.add(queryRoot, cc.xy(3, 3)); - builder.addLabel("Qualifier:", cc.xy(1, 5)); - builder.add(qualifier, cc.xy(3, 5)); - builder.addLabel("Distinct:", cc.xy(1, 7)); - builder.add(distinct, cc.xy(3, 7)); - - // the comment closes the form, below the query properties - PanelBuilder commentBuilder = new PanelBuilder( - new FormLayout(EditorForm.LABEL_COLUMN + ", $lcgap, fill:200dlu", "p")); - commentBuilder.setBorder(EditorForm.lastSectionBorder()); - commentBuilder.addLabel("Comment:", cc.xy(1, 1)); - commentBuilder.add(comment, cc.xy(3, 1)); - - JPanel propertiesAndComment = new JPanel(new BorderLayout()); - propertiesAndComment.add(properties, BorderLayout.NORTH); - propertiesAndComment.add(commentBuilder.getPanel(), BorderLayout.CENTER); - - JPanel sections = new JPanel(new BorderLayout()); - sections.add(builder.getPanel(), BorderLayout.NORTH); - sections.add(propertiesAndComment, BorderLayout.CENTER); - - // no toolbar in this editor, but its form should still start where the other editors' forms do - this.setLayout(new BorderLayout()); - this.add(EditorForm.toolBarSpacer(), BorderLayout.NORTH); - this.add(sections, BorderLayout.CENTER); - } - - private void initController() { - distinct.addItemListener(e -> { - QueryDescriptor query = getQuery(); - if (query instanceof SelectQueryDescriptor selectQuery) { - selectQuery.setDistinct(distinct.isSelected()); - session.fireQueryEvent(QueryEvent.ofChange(this, query)); - } - }); - } - - /** - * Updates the view from the current model state. Invoked when a currently displayed - * query is changed. - */ - void initFromModel() { - QueryDescriptor descriptor = session.getSelectedQuery(); - - if (!(descriptor instanceof SelectQueryDescriptor)) { - setVisible(false); - return; - } - - SelectQueryDescriptor query = (SelectQueryDescriptor) descriptor; - - name.setText(query.getName()); - distinct.setSelected(query.isDistinct()); - qualifier.setText(query.getQualifier() != null ? query - .getQualifier() - .toString() : null); - comment.setText(getQueryComment(query)); - - // init root choices and title label.. - - // - SelectQuery supports ObjEntity roots - - // TODO: now we only allow roots from the current map, - // since query root is fully resolved during map loading, - // making it impossible to reference other DataMaps. - - DataMap map = session.getSelectedDataMap(); - ObjEntity[] roots = map.getObjEntities().toArray(new ObjEntity[0]); - - if (roots.length > 1) { - Arrays.sort(roots, ProjectComparators.forDataMapChildren()); - } - - DefaultComboBoxModel model = new DefaultComboBoxModel<>(roots); - model.setSelectedItem(query.getRoot()); - queryRoot.setModel(model); - - properties.initFromModel(query); - - setVisible(true); - } - - @Override - protected SelectQueryDescriptor getQuery() { - if(session.getSelectedQuery() == null) { - return null; - } - return session.getSelectedQuery() instanceof SelectQueryDescriptor - ? (SelectQueryDescriptor) session.getSelectedQuery() - : null; - } - - void setQueryQualifier(String text) { - if (text != null && text.trim().isEmpty()) { - text = null; - } - - getQuery().setQualifier(createQualifier(text)); - session.fireQueryEvent(QueryEvent.ofChange(this, getQuery())); - } - - /** - * Method to create and check an expression - * @param text String to be converted as Expression - * @return Expression if a new expression was created, null otherwise. - * @throws ValidationException if text can't be converted - */ - Expression createQualifier(String text) throws ValidationException - { - SelectQueryDescriptor query = getQuery(); - if (query == null) { - return null; - } - - try { - String oldQualifier = ExpressionConvertor.asString(query.getQualifier()); - if (!Objects.equals(oldQualifier, text)) { - Expression exp = ExpressionConvertor.fromString(text); - - /* - * Advanced checking. See CAY-888 #1 - */ - if (query.getRoot() instanceof Entity) { - checkExpression((Entity) query.getRoot(), exp); - } - - return exp; - } - - return null; - } - catch (IllegalArgumentException ex) { - // unparsable qualifier - throw new ValidationException(ex.getMessage()); - } - } - - /** - * Advanced checking of an expression, needed because Expression.fromString() - * might terminate normally, but returned Expression will not be appliable - * for real Entities. - * Current implementation assures all attributes in expression are present in - * Entity - * @param root Root of a query - * @param ex Expression to check - * @throws ValidationException when something's wrong - */ - static void checkExpression(Entity root, Expression ex) throws ValidationException { - try { - if (ex instanceof PathExp pathExp) { - /* - * Try to iterate through path, if some attributes are not present, - * exception will be raised - */ - - Entity pathRoot = pathExp instanceof DbPathExp && root instanceof ObjEntity objEntity - ? objEntity.getDbEntity() - : root; - if (pathRoot == null) { - throw new ValidationException("Can't resolve DB path '" + pathExp + "', DbEntity is not set."); - } - - pathRoot.resolvePath(pathExp.getExpandedPath()); - } - - if (ex != null) { - for (int i = 0; i < ex.getOperandCount(); i++) { - if (ex.getOperand(i) instanceof Expression) { - checkExpression(root, (Expression)ex.getOperand(i)); - } - } - } - } - catch (ExpressionException eex) { - throw new ValidationException(eex.getUnlabeledMessage()); - } - } - - private void setQueryComment(String text) { - QueryDescriptor query = getQuery(); - if (query == null) { - return; - } - ObjectInfo.putToMetaData(app.getMetaData(), query, ObjectInfo.COMMENT, text); - session.fireQueryEvent(QueryEvent.ofChange(this, query)); - } - - private String getQueryComment(QueryDescriptor queryDescriptor) { - return ObjectInfo.getFromMetaData(app.getMetaData(), queryDescriptor, ObjectInfo.COMMENT); - } -} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryOrderingTab.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryOrderingTab.java deleted file mode 100644 index 5ffd0c9007..0000000000 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryOrderingTab.java +++ /dev/null @@ -1,346 +0,0 @@ -/***************************************************************** - * 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.modeler.ui.project.editor.query.selectquery; - -import org.apache.cayenne.map.Entity; -import org.apache.cayenne.map.QueryDescriptor; -import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.modeler.event.model.QueryEvent; -import org.apache.cayenne.modeler.toolkit.columnview.ColumnViewPanel; -import org.apache.cayenne.modeler.toolkit.icon.IconFactory; -import org.apache.cayenne.modeler.pref.adapters.SplitPanePrefs; -import org.apache.cayenne.modeler.toolkit.table.CMTable; -import org.apache.cayenne.modeler.toolkit.ProjectPanel; -import org.apache.cayenne.modeler.toolkit.AppAction; -import org.apache.cayenne.modeler.project.ProjectSession; -import org.apache.cayenne.modeler.toolkit.tree.EntityTreeModel; -import org.apache.cayenne.query.Ordering; -import org.apache.cayenne.query.SortOrder; -import org.apache.cayenne.modeler.toolkit.Renderers; - -import javax.swing.*; -import javax.swing.table.AbstractTableModel; -import javax.swing.table.TableModel; -import javax.swing.tree.TreeModel; -import java.awt.*; - -/** - * A panel for picking SelectQuery orderings. - * - */ -public class SelectQueryOrderingTab extends ProjectPanel { - - static final Dimension BROWSER_CELL_DIM = new Dimension(150, 100); - static final Dimension TABLE_DIM = new Dimension(460, 60); - - static final String PATH_HEADER = "Path"; - static final String ASCENDING_HEADER = "Ascending"; - static final String IGNORE_CASE_HEADER = "Ignore Case"; - - static final String REAL_PANEL = "real"; - static final String PLACEHOLDER_PANEL = "placeholder"; - - protected SelectQueryDescriptor selectQuery; - - protected ColumnViewPanel browser; - protected CMTable table; - - protected CardLayout cardLayout; - protected JPanel messagePanel; - - public SelectQueryOrderingTab(ProjectSession session) { - super(session); - - initView(); - initController(); - } - - protected void initView() { - - messagePanel = new JPanel(new BorderLayout()); - cardLayout = new CardLayout(); - - int defLocation = app.getFrame().getHeight() / 2; - - //As of CAY-888 #3 main pane is now a JSplitPane. Top component is a bit larger. - JSplitPane mainPanel = new JSplitPane(JSplitPane.VERTICAL_SPLIT); - new SplitPanePrefs(app.getPrefsManager().uiNode("selectQuery/orderingSplit")).bind(mainPanel, defLocation); - - mainPanel.setTopComponent(createEditorPanel()); - mainPanel.setBottomComponent(createSelectorPanel()); - - setLayout(cardLayout); - add(mainPanel, REAL_PANEL); - add(messagePanel, PLACEHOLDER_PANEL); - } - - protected void initController() { - - // scroll to selected row whenever a selection even occurs - table.getSelectionModel().addListSelectionListener(e -> { - if (!e.getValueIsAdjusting()) { - table.scrollToSelectedRow(); - } - }); - } - - protected void initFromModel() { - QueryDescriptor query = session.getSelectedQuery(); - - if (!(query instanceof SelectQueryDescriptor)) { - processInvalidModel("Unknown query."); - return; - } - - if (!(query.getRoot() instanceof Entity)) { - processInvalidModel("SelectQuery has no root set."); - return; - } - - this.selectQuery = (SelectQueryDescriptor) query; - browser.setModel(createBrowserModel((Entity) selectQuery.getRoot())); - table.setModel(createTableModel()); - - // init column sizes - table.getColumnModel().getColumn(0).setPreferredWidth(250); - - cardLayout.show(this, REAL_PANEL); - } - - protected void processInvalidModel(String message) { - JLabel messageLabel = new JLabel(message, JLabel.CENTER); - - messagePanel.removeAll(); - messagePanel.add(messageLabel, BorderLayout.CENTER); - cardLayout.show(this, PLACEHOLDER_PANEL); - } - - protected JPanel createEditorPanel() { - - table = new CMTable(); - table.setRowHeight(25); - table.setRowMargin(3); - table.setPreferredScrollableViewportSize(TABLE_DIM); - table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - - JPanel panel = new JPanel(new BorderLayout()); - panel.add(new JScrollPane(table), BorderLayout.CENTER); - - return panel; - } - - protected JPanel createSelectorPanel() { - browser = new ColumnViewPanel(); - browser.setPreferredColumnSize(BROWSER_CELL_DIM); - browser.setDefaultRenderer(); - - JPanel panel = new JPanel(new BorderLayout()); - panel.add(createToolbar(), BorderLayout.NORTH); - panel.add(new JScrollPane( - browser, - JScrollPane.VERTICAL_SCROLLBAR_NEVER, - JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED), BorderLayout.CENTER); - - // setting minimal size, otherwise scrolling looks awful, because of - // VERTICAL_SCROLLBAR_NEVER strategy - panel.setMinimumSize(panel.getPreferredSize()); - - return panel; - } - - protected JComponent createToolbar() { - - JButton add = new AppAction.CayenneToolbarButton(null, 1); - add.setText("Add Ordering"); - Icon addIcon = IconFactory.buildIcon("icon-plus.png"); - add.setIcon(addIcon); - add.setDisabledIcon(IconFactory.disabledIcon(addIcon)); - add.addActionListener(e -> addOrdering()); - - JButton remove = new AppAction.CayenneToolbarButton(null, 3); - remove.setText("Remove Ordering"); - Icon removeIcon = IconFactory.buildIcon("icon-trash.png"); - remove.setIcon(removeIcon); - remove.setDisabledIcon(IconFactory.disabledIcon(removeIcon)); - remove.addActionListener(e -> removeOrdering()); - - JToolBar toolBar = new JToolBar(); - toolBar.setBorder(BorderFactory.createEmptyBorder()); - toolBar.setFloatable(false); - toolBar.add(add); - toolBar.add(remove); - return toolBar; - } - - protected TreeModel createBrowserModel(Entity entity) { - return new EntityTreeModel(entity); - } - - protected TableModel createTableModel() { - return new OrderingModel(); - } - - protected String getSelectedPath() { - Object[] path = browser.getSelectionPath().getPath(); - - // first item in the path is Entity, so we must have - // at least two elements to constitute a valid ordering path - if (path.length < 2) { - return null; - } - - StringBuilder buffer = new StringBuilder(); - - // attribute or relationships - buffer.append(Renderers.asString(path[1])); - - for (int i = 2; i < path.length; i++) { - buffer.append(".").append(Renderers.asString(path[i])); - } - - return buffer.toString(); - } - - void addOrdering() { - String orderingPath = getSelectedPath(); - if (orderingPath == null) { - return; - } - - // check if such ordering already exists - for (Ordering ord : selectQuery.getOrderings()) { - if (orderingPath.equals(ord.getSortSpecString())) { - return; - } - } - - selectQuery.addOrdering(new Ordering(orderingPath, SortOrder.ASCENDING)); - int index = selectQuery.getOrderings().size() - 1; - - OrderingModel model = (OrderingModel) table.getModel(); - model.fireTableRowsInserted(index, index); - session.fireQueryEvent(QueryEvent.ofChange(SelectQueryOrderingTab.this, selectQuery)); - } - - void removeOrdering() { - int selection = table.getSelectedRow(); - if (selection < 0) { - return; - } - - OrderingModel model = (OrderingModel) table.getModel(); - Ordering ordering = model.getOrdering(selection); - selectQuery.removeOrdering(ordering); - - model.fireTableRowsDeleted(selection, selection); - session.fireQueryEvent(QueryEvent.ofChange(SelectQueryOrderingTab.this, selectQuery)); - } - - /** - * A table model for the Ordering editing table. - */ - final class OrderingModel extends AbstractTableModel { - - Ordering getOrdering(int row) { - return selectQuery.getOrderings().get(row); - } - - public int getColumnCount() { - return 3; - } - - public int getRowCount() { - return (selectQuery != null) ? selectQuery.getOrderings().size() : 0; - } - - public Object getValueAt(int row, int column) { - Ordering ordering = getOrdering(row); - - switch (column) { - case 0: - return ordering.getSortSpecString(); - case 1: - return ordering.isAscending() ? Boolean.TRUE : Boolean.FALSE; - case 2: - return ordering.isCaseInsensitive() ? Boolean.TRUE : Boolean.FALSE; - default: - throw new IndexOutOfBoundsException("Invalid column: " + column); - } - } - - @Override - public Class getColumnClass(int column) { - switch (column) { - case 0: - return String.class; - case 1: - case 2: - return Boolean.class; - default: - throw new IndexOutOfBoundsException("Invalid column: " + column); - } - } - - @Override - public String getColumnName(int column) { - switch (column) { - case 0: - return PATH_HEADER; - case 1: - return ASCENDING_HEADER; - case 2: - return IGNORE_CASE_HEADER; - default: - throw new IndexOutOfBoundsException("Invalid column: " + column); - } - } - - @Override - public boolean isCellEditable(int row, int column) { - return column == 1 || column == 2; - } - - @Override - public void setValueAt(Object value, int row, int column) { - Ordering ordering = getOrdering(row); - - switch (column) { - case 1: - if ((Boolean) value) { - ordering.setAscending(); - } else { - ordering.setDescending(); - } - break; - case 2: - if ((Boolean) value) { - ordering.setCaseInsensitive(); - } else { - ordering.setCaseSensitive(); - } - break; - default: - throw new IndexOutOfBoundsException("Invalid editable column: " + column); - } - - session.fireQueryEvent(QueryEvent.ofChange(SelectQueryOrderingTab.this, selectQuery)); - } - } -} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryPrefetchTab.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryPrefetchTab.java deleted file mode 100644 index addf8dd9ce..0000000000 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryPrefetchTab.java +++ /dev/null @@ -1,175 +0,0 @@ -/***************************************************************** - * 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.modeler.ui.project.editor.query.selectquery; - -import org.apache.cayenne.modeler.ui.project.editor.query.PrefetchModel; -import org.apache.cayenne.modeler.event.model.QueryEvent; -import org.apache.cayenne.map.Attribute; -import org.apache.cayenne.map.Entity; -import org.apache.cayenne.map.Relationship; -import org.apache.cayenne.modeler.project.ProjectSession; -import org.apache.cayenne.modeler.toolkit.AppAction; -import org.apache.cayenne.modeler.toolkit.tree.EntityTreeFilter; -import org.apache.cayenne.modeler.toolkit.tree.EntityTreeModel; -import org.apache.cayenne.modeler.toolkit.icon.IconFactory; - -import javax.swing.BorderFactory; -import javax.swing.DefaultCellEditor; -import javax.swing.Icon; -import javax.swing.JButton; -import javax.swing.JComboBox; -import javax.swing.JComponent; -import javax.swing.JToolBar; -import javax.swing.table.DefaultTableCellRenderer; -import javax.swing.table.TableColumn; -import javax.swing.table.TableModel; -import javax.swing.tree.TreeModel; - -/** - * Subclass of the SelectQueryOrderingTab configured to work with prefetches. - * - */ -public class SelectQueryPrefetchTab extends SelectQueryOrderingTab { - - public static final String JOINT_PREFETCH_SEMANTICS = "Joint"; - public static final String DISJOINT_PREFETCH_SEMANTICS = "Disjoint"; - public static final String DISJOINT_BY_ID_PREFETCH_SEMANTICS = "Disjoint by id"; - public static final String UNDEFINED_SEMANTICS = "Undefined semantics"; - - public SelectQueryPrefetchTab(ProjectSession session) { - super(session); - } - - protected void initFromModel(){ - super.initFromModel(); - if(table.getColumnModel().getColumns().hasMoreElements()) { - setUpPrefetchBox(table.getColumnModel().getColumn(2)); - } - } - - protected void setUpPrefetchBox(TableColumn column) { - - JComboBox prefetchBox = new JComboBox<>(); - prefetchBox.addItem(JOINT_PREFETCH_SEMANTICS); - prefetchBox.addItem(DISJOINT_PREFETCH_SEMANTICS); - prefetchBox.addItem(DISJOINT_BY_ID_PREFETCH_SEMANTICS); - - prefetchBox.addActionListener(e -> session.setDirty(true)); - - column.setCellEditor(new DefaultCellEditor(prefetchBox)); - - DefaultTableCellRenderer renderer = - new DefaultTableCellRenderer(); - renderer.setToolTipText("Click for combo box"); - column.setCellRenderer(renderer); - } - - protected JComponent createToolbar() { - - JButton add = new AppAction.CayenneToolbarButton(null, 1); - add.setText("Add Prefetch"); - Icon addIcon = IconFactory.buildIcon("icon-plus.png"); - add.setIcon(addIcon); - add.setDisabledIcon(IconFactory.disabledIcon(addIcon)); - - add.addActionListener(e -> { - String prefetch = getSelectedPath(); - - if (prefetch == null) { - return; - } - - addPrefetch(prefetch); - - app.getUndoManager().addEdit(new AddPrefetchUndoableEdit(prefetch, SelectQueryPrefetchTab.this)); - }); - - JButton remove = new AppAction.CayenneToolbarButton(null, 3); - remove.setText("Remove Prefetch"); - Icon removeIcon = IconFactory.buildIcon("icon-trash.png"); - remove.setIcon(removeIcon); - remove.setDisabledIcon(IconFactory.disabledIcon(removeIcon)); - remove.addActionListener(e -> { - int selection = table.getSelectedRow(); - - if (selection < 0) { - return; - } - - String prefetch = (String) table.getModel().getValueAt(selection, 0); - - removePrefetch(prefetch); - }); - - JToolBar toolBar = new JToolBar(); - toolBar.setBorder(BorderFactory.createEmptyBorder()); - toolBar.setFloatable(false); - toolBar.add(add); - toolBar.add(remove); - return toolBar; - } - - protected TreeModel createBrowserModel(Entity entity) { - EntityTreeModel treeModel = new EntityTreeModel(entity); - treeModel.setFilter( - new EntityTreeFilter() { - public boolean attributeMatch(Object node, Attribute attr) { - return false; - } - - public boolean relationshipMatch(Object node, Relationship rel) { - return true; - } - }); - return treeModel; - } - - protected TableModel createTableModel() { - return new PrefetchModel(selectQuery.getPrefetchesMap(), selectQuery.getRoot()); - } - - public void addPrefetch(String prefetch) { - - // check if such prefetch already exists - if (!selectQuery.getPrefetchesMap().isEmpty() && selectQuery.getPrefetchesMap().containsKey(prefetch)) { - return; - } - - //default value id disjoint - selectQuery.addPrefetch(prefetch, PrefetchModel.getPrefetchType(DISJOINT_PREFETCH_SEMANTICS)); - - // reset the model, since it is immutable - table.setModel(createTableModel()); - setUpPrefetchBox(table.getColumnModel().getColumn(2)); - - session.fireQueryEvent(QueryEvent.ofChange(this, selectQuery)); - } - - public void removePrefetch(String prefetch) { - selectQuery.removePrefetch(prefetch); - - // reset the model, since it is immutable - table.setModel(createTableModel()); - setUpPrefetchBox(table.getColumnModel().getColumn(2)); - - session.fireQueryEvent(QueryEvent.ofChange(this, selectQuery)); - } - -} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryTabbedView.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryTabbedView.java deleted file mode 100644 index 63d3bf7256..0000000000 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/SelectQueryTabbedView.java +++ /dev/null @@ -1,99 +0,0 @@ -/***************************************************************** - * 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.modeler.ui.project.editor.query.selectquery; - -import org.apache.cayenne.map.QueryDescriptor; -import org.apache.cayenne.modeler.project.ProjectSession; -import org.apache.cayenne.modeler.toolkit.ProjectTabbedPane; - -import javax.swing.*; -import javax.swing.event.ChangeEvent; -import org.apache.cayenne.map.SelectQueryDescriptor; - -public class SelectQueryTabbedView extends ProjectTabbedPane { - - private final SelectQueryMainTab mainTab; - private final SelectQueryPrefetchTab prefetchTab; - private final SelectQueryOrderingTab orderingTab; - private int lastSelectionIndex; - - public SelectQueryTabbedView(ProjectSession session) { - super(session); - this.mainTab = new SelectQueryMainTab(session); - this.orderingTab = new SelectQueryOrderingTab(session); - this.prefetchTab = new SelectQueryPrefetchTab(session); - initLayout(); - initBindings(); - } - - private void initLayout() { - setTabPlacement(JTabbedPane.TOP); - addTab("ObjectSelect", new JScrollPane(mainTab)); - addTab("Orderings", orderingTab); - addTab("Prefetches", prefetchTab); - } - - private void initBindings() { - session.addQueryDisplayListener(e -> initFromModel()); - addChangeListener(this::stateChanged); - } - - private void stateChanged(ChangeEvent e) { - lastSelectionIndex = getSelectedIndex(); - updateTabs(); - } - - private void initFromModel() { - if (!(session.getSelectedQuery() instanceof SelectQueryDescriptor)) { - setVisible(false); - return; - } - - // if no root, reset tabs to show the first panel.. - if (session.getSelectedQuery().getRoot() == null) { - lastSelectionIndex = 0; - } - - // tab did not change - force update - if (getSelectedIndex() == lastSelectionIndex) { - updateTabs(); - } - // change tab, this will update newly displayed tab... - else { - setSelectedIndex(lastSelectionIndex); - } - - setVisible(true); - } - - private void updateTabs() { - switch (lastSelectionIndex) { - case 0: - mainTab.initFromModel(); - break; - case 1: - orderingTab.initFromModel(); - break; - case 2: - prefetchTab.initFromModel(); - break; - } - } -} diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/sqltemplate/SQLTemplatePrefetchTab.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/sqltemplate/SQLTemplatePrefetchTab.java index aef546223f..1645e1721a 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/sqltemplate/SQLTemplatePrefetchTab.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/sqltemplate/SQLTemplatePrefetchTab.java @@ -20,7 +20,6 @@ package org.apache.cayenne.modeler.ui.project.editor.query.sqltemplate; import org.apache.cayenne.modeler.ui.project.editor.query.PrefetchModel; -import org.apache.cayenne.modeler.ui.project.editor.query.selectquery.SelectQueryPrefetchTab; import org.apache.cayenne.modeler.event.model.QueryEvent; import org.apache.cayenne.map.Attribute; import org.apache.cayenne.map.Entity; @@ -36,7 +35,6 @@ import org.apache.cayenne.modeler.toolkit.icon.IconFactory; import org.apache.cayenne.modeler.pref.adapters.SplitPanePrefs; import org.apache.cayenne.modeler.toolkit.table.CMTable; -import org.apache.cayenne.modeler.toolkit.Renderers; import javax.swing.BorderFactory; import javax.swing.DefaultCellEditor; @@ -147,8 +145,8 @@ protected void processInvalidModel(String message) { protected void setUpPrefetchBox(TableColumn column) { JComboBox prefetchBox = new JComboBox<>(); - prefetchBox.addItem(SelectQueryPrefetchTab.JOINT_PREFETCH_SEMANTICS); - prefetchBox.addItem(SelectQueryPrefetchTab.DISJOINT_BY_ID_PREFETCH_SEMANTICS); + prefetchBox.addItem(PrefetchModel.JOINT_PREFETCH_SEMANTICS); + prefetchBox.addItem(PrefetchModel.DISJOINT_BY_ID_PREFETCH_SEMANTICS); prefetchBox.addActionListener(e -> session.setDirty(true)); @@ -235,25 +233,7 @@ protected JComponent createToolbar() { } protected String getSelectedPath() { - - Object[] path = browser.getSelectionPath().getPath(); - - // first item in the path is Entity, so we must have - // at least two elements to constitute a valid ordering path - if (path.length < 2) { - return null; - } - - StringBuilder buffer = new StringBuilder(); - - // attribute or relationships - buffer.append(Renderers.asString(path[1])); - - for (int i = 2; i < path.length; i++) { - buffer.append(".").append(Renderers.asString(path[i])); - } - - return buffer.toString(); + return EntityTreeModel.toPath(browser.getSelectionPath()); } protected TreeModel createBrowserModel(Entity entity) { @@ -284,7 +264,8 @@ public void addPrefetch(String prefetch) { } //default value is joint - sqlTemplate.addPrefetch(prefetch, PrefetchModel.getPrefetchType(SelectQueryPrefetchTab.DISJOINT_BY_ID_PREFETCH_SEMANTICS)); + sqlTemplate.addPrefetch(prefetch, + PrefetchModel.getPrefetchType(PrefetchModel.DISJOINT_BY_ID_PREFETCH_SEMANTICS)); // reset the model, since it is immutable table.setModel(createTableModel()); diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/querytype/QueryTypeDialog.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/querytype/QueryTypeDialog.java index 3de142f462..7ed8e648b6 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/querytype/QueryTypeDialog.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/querytype/QueryTypeDialog.java @@ -24,6 +24,7 @@ import org.apache.cayenne.project.Project; import org.apache.cayenne.dbsync.naming.NameBuilder; import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.modeler.event.display.QueryDisplayEvent; import org.apache.cayenne.modeler.event.model.QueryEvent; @@ -128,6 +129,13 @@ private void createQuery() { private QueryDescriptor newQuery() { if (sqlSelect.isSelected()) return new SQLTemplateDescriptor(); if (procedureSelect.isSelected()) return new ProcedureQueryDescriptor(); - return new SelectQueryDescriptor(); + + // start the query String with the selected entity, if any + SelectQueryDescriptor query = new SelectQueryDescriptor(); + ObjEntity entity = session.getSelectedObjEntity(); + if (entity != null && entity.getDataMap() == dataMap) { + query.setQl("from " + entity.getName()); + } + return query; } } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/undo/TextCompoundEdit.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/undo/TextCompoundEdit.java index 8732959dca..29a97ce3e6 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/undo/TextCompoundEdit.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/undo/TextCompoundEdit.java @@ -23,10 +23,11 @@ import org.apache.cayenne.map.DbEntity; import org.apache.cayenne.map.Embeddable; import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.SQLTemplateDescriptor; +import org.apache.cayenne.map.SelectQueryDescriptor; import org.apache.cayenne.modeler.ui.project.ProjectView; import org.apache.cayenne.modeler.ui.project.editor.EditorPanelView; import org.apache.cayenne.modeler.ui.project.editor.query.sqltemplate.SQLTemplateTabbedView; -import org.apache.cayenne.query.SQLTemplate; import javax.swing.*; import javax.swing.event.DocumentEvent; @@ -84,7 +85,11 @@ public TextCompoundEdit(JTextComponent editor, Runnable onUndoCleanup) { tabbedPane = editorPanel.getEmbeddableView(); } - if (targetObject instanceof SQLTemplate) { + if (targetObject instanceof SelectQueryDescriptor) { + tabbedPane = editorPanel.getSelectQueryView(); + } + + if (targetObject instanceof SQLTemplateDescriptor) { tabbedPane = editorPanel.getSqlTemplateView(); if (tabbedPane != null) { @@ -172,7 +177,11 @@ public void undo() throws CannotUndoException { } editor.requestFocusInWindow(); - editor.selectAll(); + + // a single-line field is selected as a whole, a multi-line editor keeps the caret where the undo left it + if (editor instanceof JTextField) { + editor.selectAll(); + } } @Override diff --git a/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModelTest.java b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModelTest.java new file mode 100644 index 0000000000..2b227f30dd --- /dev/null +++ b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/toolkit/tree/EntityTreeModelTest.java @@ -0,0 +1,43 @@ +/***************************************************************** + * 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.modeler.toolkit.tree; + +import javax.swing.tree.TreePath; + +import org.apache.cayenne.map.ObjAttribute; +import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.ObjRelationship; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class EntityTreeModelTest { + + @Test + public void toPath() { + ObjEntity artist = new ObjEntity("Artist"); + ObjRelationship paintings = new ObjRelationship("paintings"); + ObjAttribute title = new ObjAttribute("title"); + + assertNull(EntityTreeModel.toPath(new TreePath(artist))); + assertEquals("paintings", EntityTreeModel.toPath(new TreePath(new Object[]{artist, paintings}))); + assertEquals("paintings.title", EntityTreeModel.toPath(new TreePath(new Object[]{artist, paintings, title}))); + } +} diff --git a/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParserTest.java b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParserTest.java new file mode 100644 index 0000000000..00bb242e32 --- /dev/null +++ b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLEditorParserTest.java @@ -0,0 +1,124 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.text.BadLocationException; + +import org.apache.cayenne.exp.ExpressionException; +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.ObjAttribute; +import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; +import org.fife.ui.rsyntaxtextarea.parser.ParserNotice; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class QLEditorParserTest { + + @Test + public void errorRangeOfUnexpectedToken() throws Exception { + String ql = "from Artist\nwhere name = = 1"; + assertArrayEquals(new int[]{ql.lastIndexOf('='), 1}, QLEditorParser.errorRange(parseError(ql), document(ql))); + } + + @Test + public void errorRangeAtTheEnd() throws Exception { + // the missing condition is reported past the end of the text, and shown on its last char + String ql = "from Artist where"; + assertArrayEquals(new int[]{ql.length() - 1, 1}, QLEditorParser.errorRange(parseError(ql), document(ql))); + } + + @Test + public void errorRangeOfMultiCharToken() throws Exception { + String ql = "from Artist where name = 'a' foo"; + assertArrayEquals(new int[]{ql.indexOf("foo"), 3}, QLEditorParser.errorRange(parseError(ql), document(ql))); + } + + @Test + public void occurrences() throws Exception { + String ql = "select nme from Artist\nwhere nme = 1 and db:NME = 2 and nme.x = 3"; + RSyntaxDocument doc = document(ql); + + assertEquals(List.of(ql.indexOf("nme") + ":3", ql.indexOf("nme", 10) + ":3"), + ranges(QLEditorParser.occurrences(doc, "nme"))); + assertEquals(List.of(ql.indexOf("db:NME") + ":6"), ranges(QLEditorParser.occurrences(doc, "db:NME"))); + } + + @Test + public void parse() throws Exception { + DataMap map = new DataMap("map"); + ObjEntity artist = new ObjEntity("Artist"); + artist.addAttribute(new ObjAttribute("name", String.class.getName(), artist)); + map.addObjEntity(artist); + + SelectQueryDescriptor query = new SelectQueryDescriptor(); + query.setName("q"); + map.addQueryDescriptor(query); + + List> reported = new ArrayList<>(); + QLEditorParser parser = new QLEditorParser(() -> query, reported::add); + + String valid = "from Artist where name = $name"; + query.setQl(valid); + assertEquals(List.of(), parser.parse(document(valid), QLTokenMaker.SYNTAX_STYLE).getNotices()); + assertEquals(List.of(), reported.getLast()); + + String badPath = "from Artist where nme = $name"; + query.setQl(badPath); + ParseResult result = parser.parse(document(badPath), QLTokenMaker.SYNTAX_STYLE); + assertEquals(List.of(badPath.indexOf("nme") + ":3"), notices(result)); + assertEquals(List.of("Can't resolve path 'nme' in Artist"), reported.getLast()); + + String badSyntax = "from Artist where"; + query.setQl(badSyntax); + assertEquals(List.of((badSyntax.length() - 1) + ":1"), + notices(parser.parse(document(badSyntax), QLTokenMaker.SYNTAX_STYLE))); + assertEquals(1, reported.getLast().size()); + } + + private static ExpressionException parseError(String ql) { + SelectQueryDescriptor query = new SelectQueryDescriptor(); + query.setQl(ql); + return assertThrows(ExpressionException.class, query::getParsedQuery); + } + + private static RSyntaxDocument document(String text) throws BadLocationException { + RSyntaxDocument doc = new RSyntaxDocument(null, QLTokenMaker.SYNTAX_STYLE); + doc.setSyntaxStyle(new QLTokenMaker()); + doc.insertString(0, text, null); + return doc; + } + + private static List ranges(List ranges) { + return ranges.stream().map(r -> r[0] + ":" + r[1]).toList(); + } + + private static List notices(ParseResult result) { + return result.getNotices().stream().map(ParserNotice.class::cast).map(n -> n.getOffset() + ":" + n.getLength()) + .toList(); + } +} diff --git a/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMakerTest.java b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMakerTest.java new file mode 100644 index 0000000000..86ffbb66ba --- /dev/null +++ b/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLTokenMakerTest.java @@ -0,0 +1,115 @@ +/***************************************************************** + * 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.modeler.ui.project.editor.query.selectquery; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.text.Segment; + +import org.fife.ui.rsyntaxtextarea.Token; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class QLTokenMakerTest { + + @Test + public void clauses() { + assertEquals(List.of( + "select:" + Token.RESERVED_WORD, + "distinct:" + Token.RESERVED_WORD, + "name:" + Token.IDENTIFIER, + "from:" + Token.RESERVED_WORD, + "Artist:" + Token.IDENTIFIER, + "where:" + Token.RESERVED_WORD, + "name:" + Token.IDENTIFIER, + "like:" + Token.OPERATOR, + "$name:" + Token.VARIABLE, + "and:" + Token.OPERATOR, + "price:" + Token.IDENTIFIER, + ">:" + Token.OPERATOR, + "10.5:" + Token.LITERAL_NUMBER_FLOAT, + "order:" + Token.RESERVED_WORD, + "by:" + Token.RESERVED_WORD, + "count:" + Token.FUNCTION, + "(:" + Token.SEPARATOR, + "paintings+:" + Token.IDENTIFIER, + "):" + Token.SEPARATOR, + "desc:" + Token.RESERVED_WORD + ), tokens("select distinct name from Artist where name like $name and price > 10.5 " + + "order by count(paintings+) desc")); + } + + @Test + public void pathPrefixes() { + assertEquals(List.of( + "db::" + Token.DATA_TYPE, + "NAME:" + Token.IDENTIFIER, + "=:" + Token.OPERATOR, + "enum::" + Token.DATA_TYPE, + "org.example.E.A:" + Token.IDENTIFIER + ), tokens("db:NAME = enum:org.example.E.A")); + } + + @Test + public void keywordInPath() { + // a keyword is only highlighted as a whole token + assertEquals(List.of( + "order.date:" + Token.IDENTIFIER, + "=:" + Token.OPERATOR, + "1:" + Token.LITERAL_NUMBER_DECIMAL_INT + ), tokens("order.date = 1")); + } + + @Test + public void strings() { + assertEquals(List.of( + "'it\\'s':" + Token.LITERAL_STRING_DOUBLE_QUOTE, + "\"a\":" + Token.LITERAL_STRING_DOUBLE_QUOTE, + "'open:" + Token.ERROR_STRING_DOUBLE + ), tokens("'it\\'s' \"a\" 'open")); + } + + @Test + public void literals() { + assertEquals(List.of("null:" + Token.LITERAL_BOOLEAN, "true:" + Token.LITERAL_BOOLEAN), tokens("null true")); + } + + @Test + public void emptyLine() { + assertEquals(List.of(), tokens("")); + } + + // the non-whitespace tokens of a line as "lexeme:type" + private static List tokens(String line) { + // the line is placed in the middle of a larger array, like in a document + char[] array = ("##" + line + "##").toCharArray(); + Segment segment = new Segment(array, 2, line.length()); + + List tokens = new ArrayList<>(); + for (Token t = new QLTokenMaker().getTokenList(segment, Token.NULL, 100); t != null && t.isPaintable(); + t = t.getNextToken()) { + if (t.getType() != Token.WHITESPACE) { + tokens.add(t.getLexeme() + ":" + t.getType()); + } + } + return tokens; + } +} diff --git a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/Inspection.java b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/Inspection.java index 2a24d2b15d..5c109be643 100644 --- a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/Inspection.java +++ b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/Inspection.java @@ -99,10 +99,13 @@ public enum Inspection { QUERY_NAME_DUPLICATE(Group.QUERY, "Duplicate of a query name"), QUERY_MULTI_CACHE_GROUP(Group.QUERY, "Query has several cache groups"), - SELECT_QUERY_NO_ROOT(Group.SELECT_QUERY, "Empty select query root"), - SELECT_QUERY_INVALID_QUALIFIER(Group.SELECT_QUERY, "Invalid select query qualifier", "Not implemented"), + SELECT_QUERY_NO_ROOT(Group.SELECT_QUERY, "Empty select query root", + "Select query is empty, or its root entity is not mapped"), + SELECT_QUERY_INVALID_QL(Group.SELECT_QUERY, "Invalid select query syntax"), + SELECT_QUERY_INVALID_QUALIFIER(Group.SELECT_QUERY, "Invalid select query qualifier", + "Invalid paths in the 'select', 'where' or 'having' clauses of a select query"), SELECT_QUERY_INVALID_ORDERING_PATH(Group.SELECT_QUERY, "Invalid select query ordering path"), - SELECT_QUERY_INVALID_PREFETCH_PATH(Group.SELECT_QUERY, "Invalid select query prefetch path", "Not implemented"), + SELECT_QUERY_INVALID_PREFETCH_PATH(Group.SELECT_QUERY, "Invalid select query prefetch path"), PROCEDURE_QUERY_NO_ROOT(Group.PROCEDURE_QUERY, "Empty procedure query root"), PROCEDURE_QUERY_INVALID_ROOT(Group.PROCEDURE_QUERY, "Invalid procedure query root"), diff --git a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java new file mode 100644 index 0000000000..fb6c750f93 --- /dev/null +++ b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java @@ -0,0 +1,203 @@ +/***************************************************************** + * 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.projecttools.validation; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.cayenne.exp.DbPathExp; +import org.apache.cayenne.exp.EnclosingObjectExp; +import org.apache.cayenne.exp.Expression; +import org.apache.cayenne.exp.ExpressionException; +import org.apache.cayenne.exp.ObjPathExp; +import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.exp.property.Property; +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.DbEntity; +import org.apache.cayenne.map.Entity; +import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.Relationship; +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.query.FluentSelect; +import org.apache.cayenne.query.Ordering; +import org.apache.cayenne.query.PrefetchTreeNode; + +/** + * Checks the entity names and paths of a parsed {@link SelectQueryDescriptor} against the mapping. Used by the project + * validator, and by the Modeler to mark the problems in the query String. + * + * @since 5.0 + */ +public final class SelectQueryPaths { + + /** + * A clause of a query that a problem was found in. + */ + public enum Clause { + FROM, SELECT, WHERE, HAVING, ORDER_BY, PREFETCH + } + + /** + * A name of an entity or a path that can't be resolved. + * + * @param name the entity name or the path, as written in the query String + * @param clause the clause of the query (or its subquery) that contains the name + * @param message a description of the problem + */ + public record Problem(String name, Clause clause, String message) { + } + + private final DataMap dataMap; + private final List problems = new ArrayList<>(); + + private SelectQueryPaths(DataMap dataMap) { + this.dataMap = dataMap; + } + + /** + * Returns the problems with the entity names and paths of a query, empty if there are none, or if the query + * String can't be parsed, or the query is not in a DataMap. + */ + public static List check(SelectQueryDescriptor query) { + if (query.getDataMap() == null || !query.isValid()) { + return List.of(); + } + + SelectQueryPaths paths = new SelectQueryPaths(query.getDataMap()); + paths.checkSelect(query.getParsedQuery(), true); + return paths.problems; + } + + private void checkSelect(FluentSelect query, boolean root) { + Entity entity = resolveRoot(query); + if (entity == null) { + return; + } + + // the aliases of the columns can be used in place of the paths elsewhere in the query + Set aliases = new HashSet<>(); + if (query.getColumns() != null) { + for (Property column : query.getColumns()) { + checkExpression(entity, column.getExpression(), Clause.SELECT, aliases); + if (column.getAlias() != null) { + aliases.add(column.getAlias()); + } + } + } + + checkExpression(entity, query.getWhere(), Clause.WHERE, aliases); + checkExpression(entity, query.getHaving(), Clause.HAVING, aliases); + + if (query.getOrderings() != null) { + for (Ordering ordering : query.getOrderings()) { + checkExpression(entity, ordering.getSortSpec(), Clause.ORDER_BY, aliases); + } + } + + if (root && query.getPrefetches() != null) { + for (PrefetchTreeNode node : query.getPrefetches().nonPhantomNodes()) { + if (node.getParent() != null) { + checkPrefetch(entity, node.getPath().value()); + } + } + } + } + + private Entity resolveRoot(FluentSelect query) { + if (query.getDbEntityName() != null) { + DbEntity entity = dataMap.getDbEntity(query.getDbEntityName()); + if (entity == null) { + problems.add(new Problem(query.getDbEntityName(), Clause.FROM, + "Unknown DbEntity '" + query.getDbEntityName() + "'")); + } + return entity; + } + + ObjEntity entity = dataMap.getObjEntity(query.getEntityName()); + if (entity == null) { + problems.add(new Problem(query.getEntityName(), Clause.FROM, + "Unknown ObjEntity '" + query.getEntityName() + "'")); + } + return entity; + } + + private void checkExpression(Entity entity, Expression expression, Clause clause, Set aliases) { + if (expression == null) { + return; + } + + switch (expression) { + // the path of an enclosing object is relative to the outer query, that is checked on its own + case EnclosingObjectExp ignored -> { + } + case SubqueryExp subquery -> { + if (subquery.getQuery() != null) { + checkSelect(subquery.getQuery(), false); + } + } + // there are no ObjPathExp in a query rooted in a DbEntity, all its paths are DB paths + case ObjPathExp path when !aliases.contains(path.getPath().value()) -> + checkPath(entity, path.getExpandedPath().value(), path.getPath().value(), clause); + case DbPathExp path -> { + // a DB path needs no "db:" prefix in a query rooted in a DbEntity + if (entity instanceof ObjEntity objEntity) { + if (objEntity.getDbEntity() != null) { + checkPath(objEntity.getDbEntity(), path.getExpandedPath().value(), + "db:" + path.getPath().value(), clause); + } + } else if (!aliases.contains(path.getPath().value())) { + checkPath(entity, path.getExpandedPath().value(), path.getPath().value(), clause); + } + } + default -> { + for (int i = 0; i < expression.getOperandCount(); i++) { + if (expression.getOperand(i) instanceof Expression operand) { + checkExpression(entity, operand, clause, aliases); + } + } + } + } + } + + private void checkPath(Entity entity, String path, String name, Clause clause) { + try { + entity.resolvePath(path); + } catch (ExpressionException e) { + problems.add(new Problem(name, clause, "Can't resolve path '" + name + "' in " + entity.getName())); + } + } + + private void checkPrefetch(Entity entity, String path) { + List components; + try { + components = entity.resolvePath(path); + } catch (ExpressionException e) { + problems.add(new Problem(path, Clause.PREFETCH, + "Can't resolve prefetch path '" + path + "' in " + entity.getName())); + return; + } + + if (!(components.getLast() instanceof Relationship)) { + problems.add(new Problem(path, Clause.PREFETCH, + "Prefetch path '" + path + "' doesn't end with a relationship")); + } + } +} diff --git a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryValidator.java b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryValidator.java index eaaa306e0f..77f775c311 100644 --- a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryValidator.java +++ b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryValidator.java @@ -18,15 +18,11 @@ ****************************************************************/ package org.apache.cayenne.projecttools.validation; +import java.util.List; import java.util.function.Supplier; -import org.apache.cayenne.exp.Expression; import org.apache.cayenne.exp.ExpressionException; -import org.apache.cayenne.map.DataMap; -import org.apache.cayenne.map.Entity; -import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.query.Ordering; import org.apache.cayenne.validation.ValidationResult; class SelectQueryValidator extends BaseQueryValidator { @@ -44,91 +40,36 @@ public void validate(SelectQueryDescriptor node, ValidationResult validationResu ProjectNodeValidator.Performer performer = validateQuery(node, validationResult); - performer.performIfEnabled(Inspection.SELECT_QUERY_NO_ROOT, this::checkForRoot); - - // Resolve root to Entity for further validation - Entity root = getRootForValidation(node); - - // validate path-based parts - if (root == null) { + if (node.getQl() == null || node.getQl().isBlank()) { + performer.performIfEnabled(Inspection.SELECT_QUERY_NO_ROOT, + () -> addFailure(validationResult, node, "Query '%s' is empty", node.getName())); return; } - performer.performIfEnabled(Inspection.SELECT_QUERY_INVALID_QUALIFIER, - () -> validateQualifier(root, node.getQualifier(), validationResult)); - for (final Ordering ordering : node.getOrderings()) { - performer.performIfEnabled(Inspection.SELECT_QUERY_INVALID_ORDERING_PATH, - () -> validateOrdering(node, root, ordering, validationResult)); - } - if (node.getPrefetchesMap() == null) { + + if (!node.isValid()) { + performer.performIfEnabled(Inspection.SELECT_QUERY_INVALID_QL, () -> checkSyntax(node, validationResult)); return; } - for (String prefetchPath : node.getPrefetchesMap().keySet()) { - performer.performIfEnabled(Inspection.SELECT_QUERY_INVALID_PREFETCH_PATH, - () -> validatePrefetch(root, prefetchPath, validationResult)); - } - } - private void validatePrefetch(Entity root, String path, ValidationResult validationResult) { - // TODO: andrus 03/10/2010 - should this be implemented? + List problems = SelectQueryPaths.check(node); + for (SelectQueryPaths.Problem problem : problems) { + Inspection inspection = switch (problem.clause()) { + case FROM -> Inspection.SELECT_QUERY_NO_ROOT; + case SELECT, WHERE, HAVING -> Inspection.SELECT_QUERY_INVALID_QUALIFIER; + case ORDER_BY -> Inspection.SELECT_QUERY_INVALID_ORDERING_PATH; + case PREFETCH -> Inspection.SELECT_QUERY_INVALID_PREFETCH_PATH; + }; + performer.performIfEnabled(inspection, () -> addFailure(validationResult, node, "Query '%s': %s", + node.getName(), problem.message())); + } } - private void validateOrdering(SelectQueryDescriptor query, Entity root, - Ordering ordering, ValidationResult validationResult) { - - // validate paths in ordering - String path = ordering.getSortSpecString(); + private void checkSyntax(SelectQueryDescriptor query, ValidationResult validationResult) { try { - root.resolvePath(path); + query.getParsedQuery(); } catch (ExpressionException e) { - addFailure(validationResult, query, "Invalid ordering path: '%s'", path); + addFailure(validationResult, query, "Query '%s' can't be parsed: %s", query.getName(), + e.getUnlabeledMessage()); } } - - private void validateQualifier(Entity root, Expression qualifier, ValidationResult validationResult) { - // TODO: andrus 03/10/2010 - should this be implemented? - } - - private void checkForRoot(SelectQueryDescriptor query, ValidationResult validationResult) { - // a query can only be rooted in an ObjEntity, either resolved or referenced by name - Object root = query.getRoot(); - if (!(root instanceof ObjEntity) && !(root instanceof String)) { - addFailure(validationResult, query, "Query '%s' has no root", query.getName()); - } - } - - private Entity getRootForValidation(SelectQueryDescriptor query) { - DataMap map = query.getDataMap(); - if (query.getRoot() == null && map != null) { - return null; - } - - if (query.getRoot() == map) { - // map-level query... everything is clean - return null; - } - - if (map == null) { - // maybe standalone entity, otherwise bail... - return (query.getRoot() instanceof Entity) ? (Entity) query.getRoot() : null; - } - - if (query.getRoot() instanceof Entity) { - return (Entity) query.getRoot(); - } - - // can't validate Class root - it is likely not accessible from here... - if (query.getRoot() instanceof Class) { - return null; - } - - // resolve entity - if (query.getRoot() instanceof String) { - DataMap parent = query.getDataMap(); - if (parent != null) { - return parent.getNamespace().getObjEntity((String) query.getRoot()); - } - } - - return null; - } } diff --git a/cayenne-project-tools/src/test/java/org/apache/cayenne/projecttools/validation/SelectQueryValidatorTest.java b/cayenne-project-tools/src/test/java/org/apache/cayenne/projecttools/validation/SelectQueryValidatorTest.java new file mode 100644 index 0000000000..c32a9a614c --- /dev/null +++ b/cayenne-project-tools/src/test/java/org/apache/cayenne/projecttools/validation/SelectQueryValidatorTest.java @@ -0,0 +1,124 @@ +/***************************************************************** + * 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.projecttools.validation; + +import java.util.List; + +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.map.DbAttribute; +import org.apache.cayenne.map.DbEntity; +import org.apache.cayenne.map.ObjAttribute; +import org.apache.cayenne.map.ObjEntity; +import org.apache.cayenne.map.ObjRelationship; +import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.validation.ValidationFailure; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SelectQueryValidatorTest { + + private DataMap map; + + @BeforeEach + public void createMap() { + map = new DataMap("map"); + + DbEntity artistTable = new DbEntity("ARTIST"); + artistTable.addAttribute(new DbAttribute("ARTIST_ID")); + artistTable.addAttribute(new DbAttribute("NAME")); + map.addDbEntity(artistTable); + + ObjEntity artist = new ObjEntity("Artist"); + artist.setDbEntityName("ARTIST"); + artist.addAttribute(new ObjAttribute("name", String.class.getName(), artist)); + map.addObjEntity(artist); + + ObjEntity painting = new ObjEntity("Painting"); + painting.addAttribute(new ObjAttribute("title", String.class.getName(), painting)); + map.addObjEntity(painting); + + ObjRelationship paintings = new ObjRelationship("paintings"); + paintings.setTargetEntityName("Painting"); + artist.addRelationship(paintings); + + ObjRelationship toArtist = new ObjRelationship("artist"); + toArtist.setTargetEntityName("Artist"); + painting.addRelationship(toArtist); + } + + @Test + public void valid() { + assertEquals(List.of(), validate(""" + select name, count(paintings) as total from Artist + where name like $name and exists (from Painting where artist = enclosing(self)) + having count(paintings) > 1 + order by total desc + prefetch paintings joint""")); + assertEquals(List.of(), validate("from db:ARTIST where NAME = 'a' order by ARTIST_ID")); + assertEquals(List.of(), validate("from Artist where db:NAME = 'a'")); + } + + @Test + public void empty() { + assertEquals(List.of("Query 'q' is empty"), validate(" ")); + } + + @Test + public void invalidSyntax() { + List failures = validate("from Artist where"); + assertEquals(1, failures.size()); + assertTrue(failures.get(0).startsWith("Query 'q' can't be parsed: "), failures.get(0)); + } + + @Test + public void unknownRoot() { + assertEquals(List.of("Query 'q': Unknown ObjEntity 'Gallery'"), validate("from Gallery where x = 1")); + assertEquals(List.of("Query 'q': Unknown DbEntity 'GALLERY'"), validate("from db:GALLERY")); + } + + @Test + public void invalidPaths() { + assertEquals(List.of( + "Query 'q': Can't resolve path 'nme' in Artist", + "Query 'q': Can't resolve path 'titl' in Painting", + "Query 'q': Can't resolve path 'db:NME' in ARTIST", + "Query 'q': Can't resolve path 'paintings.x' in Artist", + "Query 'q': Can't resolve prefetch path 'galleries' in Artist", + "Query 'q': Prefetch path 'name' doesn't end with a relationship"), validate(""" + select nme from Artist + where exists (from Painting where titl = 'a') and db:NME = 'b' + order by paintings.x + prefetch galleries, name""")); + } + + private List validate(String ql) { + SelectQueryDescriptor query = new SelectQueryDescriptor(); + query.setName("q"); + query.setQl(ql); + map.removeQueryDescriptor("q"); + map.addQueryDescriptor(query); + + return new DefaultProjectValidator().validate(query).getFailures().stream() + .map(ValidationFailure::getDescription) + .toList(); + } +} diff --git a/cayenne-wocompat/src/main/java/org/apache/cayenne/wocompat/EOModelProcessor.java b/cayenne-wocompat/src/main/java/org/apache/cayenne/wocompat/EOModelProcessor.java index b0d1bbc227..fae1c9dbaa 100644 --- a/cayenne-wocompat/src/main/java/org/apache/cayenne/wocompat/EOModelProcessor.java +++ b/cayenne-wocompat/src/main/java/org/apache/cayenne/wocompat/EOModelProcessor.java @@ -31,6 +31,8 @@ import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.map.SQLTemplateDescriptor; import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.ql.QLSelectPrinter; +import org.apache.cayenne.query.ObjectSelect; import org.apache.cayenne.query.Ordering; import org.apache.cayenne.query.PrefetchTreeNode; import org.apache.cayenne.query.SortOrder; @@ -228,18 +230,20 @@ protected QueryDescriptor makeQuery(EOModelHelper helper, EOObjEntity entity, St } protected QueryDescriptor makeEOQueryDescriptor(ObjEntity root, Map plistMap) { - SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); - descriptor.setRoot(root); + // assemble the query with the fluent API, and store it as a String + ObjectSelect query = ObjectSelect.query(Object.class, root.getName()); - descriptor.setDistinct("YES".equalsIgnoreCase((String) plistMap.get("usesDistinct"))); + if ("YES".equalsIgnoreCase((String) plistMap.get("usesDistinct"))) { + query.distinct(); + } Object fetchLimit = plistMap.get("fetchLimit"); if (fetchLimit != null) { try { if (fetchLimit instanceof Number n) { - descriptor.setFetchLimit(n.intValue()); + query.limit(n.intValue()); } else if (isNumeric(fetchLimit.toString())) { - descriptor.setFetchLimit(Integer.parseInt(fetchLimit.toString())); + query.limit(Integer.parseInt(fetchLimit.toString())); } } catch (NumberFormatException nfex) { // ignoring... @@ -253,7 +257,7 @@ protected QueryDescriptor makeEOQueryDescriptor(ObjEntity root, Map plistMap) { boolean asc = !"compareDescending:".equals(ordering.get("selectorName")); String key = ordering.get("key"); if (key != null) { - descriptor.addOrdering(new Ordering(key, asc ? SortOrder.ASCENDING : SortOrder.DESCENDING)); + query.orderBy(new Ordering(key, asc ? SortOrder.ASCENDING : SortOrder.DESCENDING)); } } } @@ -261,7 +265,7 @@ protected QueryDescriptor makeEOQueryDescriptor(ObjEntity root, Map plistMap) { // qualifiers Map qualifierMap = (Map) plistMap.get("qualifier"); if (qualifierMap != null && !qualifierMap.isEmpty()) { - descriptor.setQualifier(EOFetchSpecificationParser.makeQualifier((EOObjEntity) root, qualifierMap)); + query.where(EOFetchSpecificationParser.makeQualifier((EOObjEntity) root, qualifierMap)); } // prefetches @@ -269,10 +273,13 @@ protected QueryDescriptor makeEOQueryDescriptor(ObjEntity root, Map plistMap) { if (prefetches != null && !prefetches.isEmpty()) { Iterator it = prefetches.iterator(); while (it.hasNext()) { - descriptor.addPrefetch((String) it.next(), PrefetchTreeNode.JOINT_PREFETCH_SEMANTICS); + query.prefetch((String) it.next(), PrefetchTreeNode.JOINT_PREFETCH_SEMANTICS); } } + SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); + descriptor.setQl(QLSelectPrinter.print(query)); + // data rows - note that we do not support fetching individual columns // in the // modeler... diff --git a/cayenne-wocompat/src/test/java/org/apache/cayenne/wocompat/EOFetchSpecificationParserTest.java b/cayenne-wocompat/src/test/java/org/apache/cayenne/wocompat/EOFetchSpecificationParserTest.java index 8761dd4cc1..7c2175e898 100644 --- a/cayenne-wocompat/src/test/java/org/apache/cayenne/wocompat/EOFetchSpecificationParserTest.java +++ b/cayenne-wocompat/src/test/java/org/apache/cayenne/wocompat/EOFetchSpecificationParserTest.java @@ -22,7 +22,7 @@ import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.query.ObjectSelect; +import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.query.Ordering; import org.apache.cayenne.query.PrefetchTreeNode; import org.junit.jupiter.api.Test; @@ -52,12 +52,12 @@ public void loadedFetchSpecification() throws Exception { SelectQueryDescriptor selectDescriptor = assertInstanceOf(SelectQueryDescriptor.class, descriptor); assertSame(map.getObjEntity("Entity1"), selectDescriptor.getRoot()); - assertNotNull(selectDescriptor.getQualifier()); + FluentSelect query = selectDescriptor.buildQuery(); + + assertNotNull(query.getWhere()); assertEquals( "(name = \"aa\") and (db:ID >= 7) and ((e2.name = \"bb\") or (db:e2.ID != 5))", - selectDescriptor.getQualifier().toString()); - - ObjectSelect query = selectDescriptor.buildQuery(); + query.getWhere().toString()); assertNotNull(query.getPrefetches()); Collection prefetches = query.getPrefetches().getChildren(); diff --git a/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java b/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java index 2c03049e15..a8d3835739 100644 --- a/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java +++ b/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java @@ -309,6 +309,7 @@ public void addQueryDescriptor(QueryDescriptor queryDescriptor) { } queryDescriptorMap.put(queryDescriptor.getName(), queryDescriptor); + queryDescriptor.setDataMap(this); } /** @@ -317,7 +318,10 @@ public void addQueryDescriptor(QueryDescriptor queryDescriptor) { * @since 4.0 */ public void removeQueryDescriptor(String queryName) { - queryDescriptorMap.remove(queryName); + QueryDescriptor removed = queryDescriptorMap.remove(queryName); + if (removed != null) { + removed.setDataMap(null); + } } /** @@ -474,6 +478,7 @@ public void renameDbEntity(DbEntity entity, String newName) { entity.renameSelf(newName); dbEntityMap.remove(oldName); dbEntityMap.put(newName, entity); + renameEntityInQueries(oldName, newName, true); MappingNamespace ns = getNamespace(); if (ns instanceof EntityResolver) { @@ -503,6 +508,7 @@ public void renameObjEntity(ObjEntity entity, String newName) { entity.renameSelf(newName); objEntityMap.remove(oldName); objEntityMap.put(newName, entity); + renameEntityInQueries(oldName, newName, false); MappingNamespace ns = getNamespace(); if (ns instanceof EntityResolver) { @@ -510,6 +516,15 @@ public void renameObjEntity(ObjEntity entity, String newName) { } } + // the select queries reference entities by name in their query Strings, so a rename has to follow into them + private void renameEntityInQueries(String oldName, String newName, boolean db) { + for (QueryDescriptor query : queryDescriptorMap.values()) { + if (query instanceof SelectQueryDescriptor select) { + select.renameEntity(oldName, newName, db); + } + } + } + /** * Returns an unmodifiable collection of ObjEntities stored in this DataMap. */ diff --git a/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java b/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java index 6217fc14f7..5a17f6490f 100644 --- a/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java +++ b/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java @@ -19,225 +19,311 @@ package org.apache.cayenne.map; import org.apache.cayenne.CayenneRuntimeException; -import org.apache.cayenne.project.ProjectNodeVisitor; import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.ql.QLSelectPrinter; +import org.apache.cayenne.exp.ExpressionException; +import org.apache.cayenne.exp.ExpressionParameter; +import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.exp.TraversalHandler; +import org.apache.cayenne.exp.property.Property; +import org.apache.cayenne.project.ProjectNodeVisitor; +import org.apache.cayenne.query.ColumnSelect; +import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.query.ObjectSelect; import org.apache.cayenne.query.Ordering; +import org.apache.cayenne.query.PrefetchTreeNode; import org.apache.cayenne.query.QueryCacheStrategy; -import org.apache.cayenne.query.QueryMetadata; import org.apache.cayenne.util.XMLEncoder; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** + * A mapped select query, defined by a String in the syntax of {@link ObjectSelect#parse(String, Object...)}. + * The root of the query is defined by its "from" clause, so it is null while the String can't be parsed. + * The settings that the String has no syntax for (caching, page size, etc.) are the properties of the + * descriptor. + * * @since 4.0 */ public class SelectQueryDescriptor extends QueryDescriptor { - protected Expression qualifier; - protected List orderings = new ArrayList<>(); - protected Map prefetchesMap = new HashMap<>(); - protected int fetchLimit = QueryMetadata.FETCH_LIMIT_DEFAULT; - protected int fetchOffset = QueryMetadata.FETCH_OFFSET_DEFAULT; - protected boolean distinct; + // a single or double quoted string literal of the query grammar, with its backslash escapes + private static final String QUOTED_STRING = "'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\""; - public boolean isDistinct() { - return distinct; - } + private String ql; - public void setDistinct(boolean distinct) { - this.distinct = distinct; + // the parsed "ql", or the error of parsing it, both lazily set. Inspected, but never run, as it is not a copy + private FluentSelect parsed; + private ExpressionException parseError; + + /** + * Returns the query String, exactly as it was set. + * + * @since 5.0 + */ + public String getQl() { + return ql; } /** - * Returns the fetch limit of the query. Zero means no limit. + * Sets the query String. The String is stored as is, and is not validated until the query is parsed. * * @since 5.0 */ - public int getFetchLimit() { - return fetchLimit; + public void setQl(String ql) { + this.ql = ql; + this.parsed = null; + this.parseError = null; } /** + * Returns whether the query String can be parsed. + * * @since 5.0 */ - public void setFetchLimit(int fetchLimit) { - this.fetchLimit = fetchLimit; + public boolean isValid() { + return parsedOrNull() != null; } /** - * Returns the fetch offset of the query. + * Returns the parsed query String with its "$name" parameters unbound. The returned query is shared and must not + * be modified or run, use {@link #buildQuery(Map)} to get a query to run. * + * @throws ExpressionException if the String is empty or can't be parsed * @since 5.0 */ - public int getFetchOffset() { - return fetchOffset; + public FluentSelect getParsedQuery() { + FluentSelect query = parsedOrNull(); + if (query == null) { + throw parseError; + } + return query; + } + + private FluentSelect parsedOrNull() { + if (parsed == null && parseError == null) { + if (ql == null || ql.isBlank()) { + parseError = new ExpressionException("The query is empty"); + } else { + try { + parsed = ObjectSelect.parse(ql); + } catch (ExpressionException e) { + parseError = e; + } + } + } + return parsed; } /** + * Returns whether this query selects columns, and not the root objects. + * * @since 5.0 */ - public void setFetchOffset(int fetchOffset) { - this.fetchOffset = fetchOffset; + public boolean isColumnQuery() { + return parsedOrNull() instanceof ColumnSelect; } /** - * Returns qualifier of this query. + * Returns whether this query is rooted in a DbEntity. + * + * @since 5.0 */ - public Expression getQualifier() { - return qualifier; + public boolean isDbQuery() { + FluentSelect query = parsedOrNull(); + return query != null && query.getDbEntityName() != null; } /** - * Sets qualifier for this query. + * Returns the names of the "$name" parameters of the query, in the order of their first occurrence. + * + * @throws ExpressionException if the query String can't be parsed + * @since 5.0 */ - public void setQualifier(Expression qualifier) { - this.qualifier = qualifier; + public Set getParameterNames() { + Set names = new LinkedHashSet<>(); + collectParameterNames(getParsedQuery(), names); + return names; + } + + private static void collectParameterNames(FluentSelect query, Set names) { + ParameterCollector collector = new ParameterCollector(names); + if (query.getColumns() != null) { + for (Property column : query.getColumns()) { + collector.collect(column.getExpression()); + } + } + collector.collect(query.getWhere()); + collector.collect(query.getHaving()); + if (query.getOrderings() != null) { + for (Ordering ordering : query.getOrderings()) { + collector.collect(ordering.getSortSpec()); + } + } } /** - * Returns list of orderings for this query. + * Renames an entity in the "from" clauses of the query String, those of the subqueries included. The rest of the + * String, its formatting and string literals included, is kept as is. The String is not parsed, so it may be one + * that can't be. + * + * @param db whether the entity is a DbEntity, i.e. is referenced as "from db:NAME" */ - public List getOrderings() { - return orderings; + void renameEntity(String oldName, String newName, boolean db) { + if (ql == null || oldName.equals(newName)) { + return; + } + + // A scan of the String is exact for any String that parses: the grammar has no comments, and "from" followed + // by a simple name is a "from" clause anywhere but inside a string literal, which is why the literals are + // matched (and kept) too. The keyword is case-sensitive, and the name must be a whole token. + Pattern reference = Pattern.compile(QUOTED_STRING + "|(\\bfrom\\s+" + (db ? "db:\\s*" : "") + ")" + + Pattern.quote(oldName) + "(?![\\w$.])", Pattern.DOTALL); + String renamed = reference.matcher(ql) + .replaceAll(m -> Matcher.quoteReplacement(m.group(1) == null ? m.group() : m.group(1) + newName)); + if (!renamed.equals(ql)) { + setQl(renamed); + } } /** - * Sets list of orderings for this query. + * Returns the root of the query defined by its "from" clause: an ObjEntity, a DbEntity or, if no such entity is + * mapped, its name. Returns null if the query String can't be parsed. */ - public void setOrderings(List orderings) { - this.orderings = orderings; + @Override + public Object getRoot() { + FluentSelect query = parsedOrNull(); + if (query == null) { + return null; + } + + boolean dbRoot = query.getDbEntityName() != null; + String rootName = dbRoot ? query.getDbEntityName() : query.getEntityName(); + if (dataMap == null) { + return rootName; + } + + Object entity = dbRoot ? dataMap.getDbEntity(rootName) : dataMap.getObjEntity(rootName); + return entity != null ? entity : rootName; } /** - * Adds single ordering for this query. + * Not supported, as the root is defined by the "from" clause of the query String. */ - public void addOrdering(Ordering ordering) { - this.orderings.add(ordering); + @Override + public void setRoot(Object root) { + throw new UnsupportedOperationException("The root of a select query is defined by its 'from' clause"); } /** - * Removes single ordering from this query. + * @deprecated since 5.0, as the query is defined by a String, see {@link #getParsedQuery()} */ - public void removeOrdering(Ordering ordering) { - this.orderings.remove(ordering); + @Deprecated(since = "5.0", forRemoval = true) + public boolean isDistinct() { + FluentSelect query = parsedOrNull(); + return query != null && query.isDistinct(); } /** - * Returns map of prefetch paths with semantics for this query. + * Returns the "where" clause of the query, or null if it has none or can't be parsed. * - * @since 4.1 + * @deprecated since 5.0, as the query is defined by a String, see {@link #getParsedQuery()} */ - public Map getPrefetchesMap() { - return prefetchesMap; + @Deprecated(since = "5.0", forRemoval = true) + public Expression getQualifier() { + FluentSelect query = parsedOrNull(); + return query != null ? query.getWhere() : null; } /** - * Sets map of prefetch paths with semantics for this query. + * Returns a list of the orderings of the query, empty if it has none or can't be parsed. * - * @since 4.1 + * @deprecated since 5.0, as the query is defined by a String, see {@link #getParsedQuery()} */ - public void setPrefetchesMap(HashMap prefetchesMap){ - this.prefetchesMap = prefetchesMap; + @Deprecated(since = "5.0", forRemoval = true) + public List getOrderings() { + FluentSelect query = parsedOrNull(); + return query != null && query.getOrderings() != null + ? new ArrayList<>(query.getOrderings()) + : new ArrayList<>(); } /** - * Adds prefetch path with semantics to this query. + * Returns a map of the prefetch paths of the query with their semantics, empty if it has none or can't be parsed. * * @since 4.1 + * @deprecated since 5.0, as the query is defined by a String, see {@link #getParsedQuery()} */ - public void addPrefetch(String prefetchPath, int semantics){ - this.prefetchesMap.put(prefetchPath, semantics); + @Deprecated(since = "5.0", forRemoval = true) + public Map getPrefetchesMap() { + Map prefetches = new HashMap<>(); + FluentSelect query = parsedOrNull(); + if (query != null && query.getPrefetches() != null) { + for (PrefetchTreeNode node : query.getPrefetches().nonPhantomNodes()) { + // the root of the tree is not a prefetch + if (node.getParent() != null) { + prefetches.put(node.getPath().value(), node.getSemantics()); + } + } + } + return prefetches; } /** - * Removes single prefetch path from this query. + * Returns the query with its "$name" parameters unbound. */ - public void removePrefetch(String prefetchPath) { - this.prefetchesMap.remove(prefetchPath); - } - @Override - public ObjectSelect buildQuery() { - return buildQuery(getQualifier()); + public FluentSelect buildQuery() { + return configure(parse(null)); } /** + * Returns the query with its "$name" parameters bound to the values from the map. The smallest condition that + * contains a parameter missing from the map is pruned. + * * @since 5.0 */ @Override - public ObjectSelect buildQuery(Map parameters) { - Expression qualifier = getQualifier(); - return buildQuery(qualifier != null ? qualifier.params(parameters, true) : null); + public FluentSelect buildQuery(Map parameters) { + return configure(parse(parameters != null ? parameters : Map.of())); } - private ObjectSelect buildQuery(Expression qualifier) { - // resolve root - Object root = getRoot(); - String rootEntityName; - if(root instanceof ObjEntity) { - rootEntityName = ((ObjEntity) root).getName(); - } else if(root instanceof String) { - rootEntityName = (String)root; - } else { - throw new CayenneRuntimeException("Unexpected root for the SelectQueryDescriptor '%s'.", root); + // each call parses the String anew, so that the query can be modified by the caller + private FluentSelect parse(Map parameters) { + if (ql == null || ql.isBlank()) { + throw new CayenneRuntimeException("Query '%s' is empty", name); } - ObjectSelect query = ObjectSelect.query(Object.class).where(qualifier); - query.entityName(rootEntityName); - - List orderings = this.getOrderings(); - if (orderings != null && !orderings.isEmpty()) { - query.orderBy(orderings); - } - - if (prefetchesMap != null) { - prefetchesMap.forEach(query::prefetch); + try { + return ObjectSelect.parse(ql, parameters); + } catch (ExpressionException e) { + throw new CayenneRuntimeException("Query '%s' can't be parsed: %s", e, name, e.getUnlabeledMessage()); } + } - query.limit(fetchLimit) - .offset(fetchOffset) - .pageSize(pageSize) + private FluentSelect configure(FluentSelect query) { + query.pageSize(pageSize) .statementFetchSize(statementFetchSize) .cacheStrategy(cacheStrategy, cacheGroup); - if (fetchingDataRows) { - query.fetchDataRows(); - } - - if (distinct) { - query.distinct(); + // a query rooted in a DbEntity fetches DataRows anyway, and a column query can't fetch them + if (fetchingDataRows && query instanceof ObjectSelect select) { + select.fetchDataRows(); } return query; } - /** - * Returns this query as a String in the syntax of {@link ObjectSelect#parse(String, Object...)}, or null if - * the query has no root entity, and hence can't be printed. - * - * @since 5.0 - */ - public String toQueryString() { - return rootEntityName() != null ? QLSelectPrinter.print(buildQuery(getQualifier())) : null; - } - - private String rootEntityName() { - return switch (root) { - case ObjEntity entity -> entity.getName(); - case String n -> n; - case null, default -> null; - }; - } - @Override public void encodeAsXML(XMLEncoder encoder, ProjectNodeVisitor delegate) { - // the root, qualifier, orderings, prefetches, limit, offset and distinct are all clauses of the query String, - // the rest of the properties are stored separately + // the String defines the root and all the clauses of the query, the rest of the properties are stored + // separately encoder.start("objectQuery") .attribute("name", getName()) .attribute("cacheStrategy", cacheStrategy != QueryCacheStrategy.NO_CACHE ? cacheStrategy.name() : null) @@ -245,9 +331,8 @@ public void encodeAsXML(XMLEncoder encoder, ProjectNodeVisitor delegate) { .attribute("pageSize", pageSize) .attribute("statementFetchSize", statementFetchSize); - String select = toQueryString(); - if (select != null) { - encoder.start("ql").cdata(select, true).end(); + if (ql != null && !ql.isBlank()) { + encoder.start("ql").cdata(ql, true).end(); } encodeCacheGroup(encoder); @@ -255,4 +340,37 @@ public void encodeAsXML(XMLEncoder encoder, ProjectNodeVisitor delegate) { delegate.visitQuery(this); encoder.end(); } + + // collects the names of the unbound "$name" parameters of the expressions, including those of the subqueries + private record ParameterCollector(Set names) implements TraversalHandler { + + void collect(Expression expression) { + if (expression != null) { + expression.traverse(this); + } + } + + @Override + public void startNode(Expression node, Expression parentNode) { + // a subquery keeps its select out of the expression tree + if (node instanceof SubqueryExp subquery && subquery.getQuery() != null) { + collectParameterNames(subquery.getQuery(), names); + } + } + + @Override + public void objectNode(Object leaf, Expression parentNode) { + switch (leaf) { + case ExpressionParameter parameter -> names.add(parameter.getName()); + // a list of values is stored as a single array operand + case Object[] values -> { + for (Object value : values) { + objectNode(value, parentNode); + } + } + case null, default -> { + } + } + } + } } diff --git a/cayenne/src/main/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandler.java b/cayenne/src/main/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandler.java index 8b8eb9dc7b..804cbe048a 100644 --- a/cayenne/src/main/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandler.java +++ b/cayenne/src/main/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandler.java @@ -18,23 +18,12 @@ ****************************************************************/ package org.apache.cayenne.project.xml; -import org.apache.cayenne.ConfigurationException; import org.apache.cayenne.map.DataMap; -import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.ql.JavaCharStream; -import org.apache.cayenne.ql.QLParser; -import org.apache.cayenne.ql.QLParserTokenManager; -import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.query.ObjectSelect; -import org.apache.cayenne.query.PrefetchTreeNode; import org.xml.sax.Attributes; import org.xml.sax.SAXException; -import java.io.StringReader; -import java.util.ArrayList; -import java.util.HashMap; - /** * A handler of the "objectQuery" tag of a DataMap. The query is defined by a single String in the syntax of * {@link ObjectSelect#parse(String, Object...)}. @@ -80,70 +69,12 @@ protected boolean processCharData(String localName, String data) { } /** - * Sets the query from its String form. The String defines the root entity, qualifier, orderings, prefetches, - * limit, offset and "distinct" of the query. "$name" parameters in the String are preserved as named parameters, - * to be bound when the query is executed. + * Sets the query from its String form. The String is not parsed here: a String that can't be parsed is kept as is, + * and fails when the query is run, or the project is validated. */ private void setSelect(String select) { - if (select.isBlank()) { - return; - } - - // TODO: the parsed query can be a ColumnSelect, have a "having" clause or be rooted in a DbEntity, none of - // which is representable in SelectQueryDescriptor, or editable in the Modeler. Both need to be reworked - // to hold a full query instead of its parts, and MappedSelect should stop assuming an ObjectSelect - String name = descriptor.getName(); - FluentSelect parsed = parseSelect(select); - if (!(parsed instanceof ObjectSelect query)) { - throw new ConfigurationException("Query '%s' selects columns, which is not supported in a mapped query: %s", - name, select); - } - if (query.getHaving() != null) { - throw new ConfigurationException("Query '%s' has a 'having' clause, which is not supported in a mapped " + - "query: %s", name, select); - } - if (query.getEntityName() == null) { - throw new ConfigurationException("Query '%s' must be rooted in an ObjEntity: %s", name, select); - } - - // a select query is rooted in an ObjEntity, and never in a DataMap. If the entity is not in this DataMap, it is - // kept by name - ObjEntity rootEntity = map.getObjEntity(query.getEntityName()); - descriptor.setRoot(rootEntity != null ? rootEntity : query.getEntityName()); - descriptor.setQualifier(query.getWhere()); - descriptor.setOrderings(query.getOrderings() != null - ? new ArrayList<>(query.getOrderings()) - : new ArrayList<>()); - - HashMap prefetchesMap = new HashMap<>(); - PrefetchTreeNode prefetches = query.getPrefetches(); - if (prefetches != null) { - for (PrefetchTreeNode node : prefetches.nonPhantomNodes()) { - // the root of the tree is not a prefetch - if (node.getParent() != null) { - prefetchesMap.put(node.getPath().value(), node.getSemantics()); - } - } - } - descriptor.setPrefetchesMap(prefetchesMap); - - descriptor.setFetchLimit(query.getLimit()); - descriptor.setFetchOffset(query.getOffset()); - descriptor.setDistinct(query.isDistinct()); - } - - /** - * Parses a query String without binding its "$name" parameters, unlike - * {@link ObjectSelect#parse(String, Object...)} that requires the values of all the parameters upfront. - */ - private FluentSelect parseSelect(String select) { - JavaCharStream stream = new JavaCharStream(new StringReader(select), 1, 1, select.length() + 1); - QLParser parser = new QLParser(new QLParserTokenManager(stream)); - try { - return parser.query(); - } catch (Throwable th) { - throw new ConfigurationException("Query '%s' can't be parsed: %s", th, descriptor.getName(), - th.getMessage()); + if (!select.isBlank()) { + descriptor.setQl(select); } } } diff --git a/cayenne/src/main/java/org/apache/cayenne/query/MappedSelect.java b/cayenne/src/main/java/org/apache/cayenne/query/MappedSelect.java index 38f0fde3f0..499a04fca1 100644 --- a/cayenne/src/main/java/org/apache/cayenne/query/MappedSelect.java +++ b/cayenne/src/main/java/org/apache/cayenne/query/MappedSelect.java @@ -175,7 +175,7 @@ protected Query createReplacementQuery(EntityResolver resolver) { switch (descriptor) { case SelectQueryDescriptor ignored -> { - ObjectSelect selectQuery = (ObjectSelect) query; + FluentSelect selectQuery = (FluentSelect) query; if (fetchLimit != null) { selectQuery.limit(fetchLimit); } diff --git a/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java b/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java index a309c5d153..35c758024b 100644 --- a/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java @@ -19,23 +19,12 @@ package org.apache.cayenne.map; -import org.apache.cayenne.project.EmptyProjectNodeVisitor; -import org.apache.cayenne.project.xml.XMLDataMapLoader; -import org.apache.cayenne.resource.URLResource; -import org.apache.cayenne.util.XMLEncoder; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.net.URL; import java.util.Collection; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; /** * DataMap unit tests. @@ -54,6 +43,65 @@ public void defaultSchema() { assertNull(map.getDefaultSchema()); } + @Test + public void renameObjEntityUpdatesSelectQueries() { + DataMap map = new DataMap("map"); + ObjEntity artist = new ObjEntity("Artist"); + map.addObjEntity(artist); + map.addObjEntity(new ObjEntity("Painting")); + + SelectQueryDescriptor rooted = selectQuery("rooted", "from Artist\nwhere name = $name"); + SelectQueryDescriptor subquery = selectQuery("subquery", + "from Painting where exists (from Artist where artist = enclosing(self))"); + SelectQueryDescriptor unrelated = selectQuery("unrelated", "from Painting where artist.name = 'Artist'"); + SelectQueryDescriptor invalid = selectQuery("invalid", "from Artist where"); + map.addQueryDescriptor(rooted); + map.addQueryDescriptor(subquery); + map.addQueryDescriptor(unrelated); + map.addQueryDescriptor(invalid); + + map.renameObjEntity(artist, "Painter"); + + assertEquals("from Painter\nwhere name = $name", rooted.getQl()); + assertSame(artist, rooted.getRoot()); + assertEquals("from Painting where exists (from Painter where artist = enclosing(self))", subquery.getQl()); + assertEquals("from Painting where artist.name = 'Artist'", unrelated.getQl()); + + // a String that can't be parsed is renamed too, as it is kept to be fixed + assertEquals("from Painter where", invalid.getQl()); + } + + @Test + public void renameDbEntityUpdatesSelectQueries() { + DataMap map = new DataMap("map"); + DbEntity table = new DbEntity("ARTIST"); + map.addDbEntity(table); + map.addObjEntity(new ObjEntity("ARTIST")); + + SelectQueryDescriptor db = selectQuery("db", "from db:ARTIST where NAME = 'a'"); + SelectQueryDescriptor obj = selectQuery("obj", "from ARTIST where NAME = 'a'"); + map.addQueryDescriptor(db); + map.addQueryDescriptor(obj); + + map.renameDbEntity(table, "PAINTER"); + + assertEquals("from db:PAINTER where NAME = 'a'", db.getQl()); + assertSame(table, db.getRoot()); + assertEquals("from ARTIST where NAME = 'a'", obj.getQl()); + } + + @Test + public void removeQueryDescriptorDetachesIt() { + DataMap map = new DataMap("map"); + SelectQueryDescriptor query = selectQuery("q", "from Artist"); + map.addQueryDescriptor(query); + assertSame(map, query.getDataMap()); + + map.removeQueryDescriptor("q"); + assertNull(query.getDataMap()); + assertNull(map.getQueryDescriptor("q")); + } + @Test public void defaultPackage() { DataMap map = new DataMap(); @@ -350,48 +398,10 @@ protected void checkProcedures(DataMap map, String[] expectedNames) throws Excep } } - @Disabled("this test is broken for many reasons " + - "(URL can't be mocked, encoder doesn't provide version, loader requires XMLReader proovider)") - @Test - public void quoteSqlIdentifiersEncodeAsXML() { - DataMap map = new DataMap("aaa"); - map.setQuotingSQLIdentifiers(true); - StringWriter w = new StringWriter(); - XMLEncoder encoder = new XMLEncoder(new PrintWriter(w)); - map.encodeAsXML(encoder, new EmptyProjectNodeVisitor()); - - assertTrue(map.quotingSQLIdentifiers); - - XMLDataMapLoader loader = new XMLDataMapLoader(); - try { - URL url = mock(URL.class); - InputStream is = new ByteArrayInputStream(w.getBuffer().toString().getBytes("UTF-8")); - when(url.openStream()).thenReturn(is); - - DataMap newMap = loader.load(new URLResource(url)); - assertTrue(newMap.quotingSQLIdentifiers); - - } catch (Exception e) { - e.printStackTrace(); - } - - map.setQuotingSQLIdentifiers(false); - StringWriter w2 = new StringWriter(); - XMLEncoder encoder2 = new XMLEncoder(new PrintWriter(w2)); - map.encodeAsXML(encoder2, new EmptyProjectNodeVisitor()); - - assertFalse(map.quotingSQLIdentifiers); - try { - URL url = mock(URL.class); - InputStream is = new ByteArrayInputStream(w.getBuffer().toString().getBytes("UTF-8")); - when(url.openStream()).thenReturn(is); - - DataMap newMap = loader.load(new URLResource(url)); - assertFalse(newMap.quotingSQLIdentifiers); - - } catch (Exception e) { - e.printStackTrace(); - } - + private static SelectQueryDescriptor selectQuery(String name, String ql) { + SelectQueryDescriptor query = new SelectQueryDescriptor(); + query.setName(name); + query.setQl(ql); + return query; } } diff --git a/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java b/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java index 71556536f9..14249a0c46 100644 --- a/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java @@ -21,10 +21,17 @@ import java.io.PrintWriter; import java.io.StringWriter; +import java.util.List; import java.util.Map; +import java.util.Set; -import org.apache.cayenne.project.EmptyProjectNodeVisitor; +import org.apache.cayenne.CayenneRuntimeException; +import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ExpressionFactory; +import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.project.EmptyProjectNodeVisitor; +import org.apache.cayenne.query.ColumnSelect; +import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.query.ObjectSelect; import org.apache.cayenne.query.Ordering; import org.apache.cayenne.query.PrefetchTreeNode; @@ -38,70 +45,104 @@ public class SelectQueryDescriptorTest { @Test - public void getQueryType() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - assertTrue(builder.buildQuery() instanceof ObjectSelect); + public void buildQuery() { + SelectQueryDescriptor descriptor = descriptor("from FakeRoot where abc = 5"); + + FluentSelect query = descriptor.buildQuery(); + assertInstanceOf(ObjectSelect.class, query); + assertEquals("FakeRoot", query.getEntityName()); + assertEquals(ExpressionFactory.exp("abc = 5"), query.getWhere()); } @Test - public void getQueryRoot() { - DataMap map = new DataMap(); - ObjEntity entity = new ObjEntity("A"); - map.addObjEntity(entity); + public void buildQueryIsACopy() { + SelectQueryDescriptor descriptor = descriptor("from FakeRoot"); + assertNotSame(descriptor.buildQuery(), descriptor.buildQuery()); + assertNotSame(descriptor.getParsedQuery(), descriptor.buildQuery()); + } - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot(entity); + @Test + public void buildQueryWithParameters() { + SelectQueryDescriptor descriptor = descriptor("from FakeRoot where abc = $a and def = $b"); - assertTrue(builder.buildQuery() instanceof ObjectSelect); - assertEquals(entity.getName(), builder.buildQuery().getEntityName()); + // parameters with no matching value are pruned from the qualifier + assertEquals(ExpressionFactory.exp("abc = 5"), descriptor.buildQuery(Map.of("a", 5)).getWhere()); } @Test - public void getQueryQualifier() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - builder.setQualifier(ExpressionFactory.exp("abc = 5")); + public void buildQueryWithoutParameters() { + SelectQueryDescriptor descriptor = descriptor("from FakeRoot where abc = $a"); + assertNull(descriptor.buildQuery(Map.of()).getWhere()); + assertEquals(ExpressionFactory.exp("abc = $a"), descriptor.buildQuery().getWhere()); + } - ObjectSelect query = builder.buildQuery(); + @Test + public void buildQueryWithSubqueryParameters() { + SelectQueryDescriptor descriptor = descriptor( + "from Artist where exists (from Painting where artist = enclosing(self) and price > $price)"); - assertEquals(ExpressionFactory.exp("abc = 5"), query.getWhere()); + SubqueryExp bound = (SubqueryExp) descriptor.buildQuery(Map.of("price", 5)).getWhere().getOperand(0); + assertEquals(ExpressionFactory.exp("artist = enclosing(self) and price > 5"), bound.getQuery().getWhere()); + + SubqueryExp pruned = (SubqueryExp) descriptor.buildQuery(Map.of()).getWhere().getOperand(0); + assertEquals(ExpressionFactory.exp("artist = enclosing(self)"), pruned.getQuery().getWhere()); } @Test - public void buildQueryWithParameters() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - builder.setQualifier(ExpressionFactory.exp("abc = $a and def = $b")); + public void buildColumnQuery() { + SelectQueryDescriptor descriptor = descriptor("select name, count(*) from Artist having count(*) > $min"); + descriptor.setPageSize(10); - ObjectSelect query = builder.buildQuery(Map.of("a", 5)); + FluentSelect query = descriptor.buildQuery(Map.of("min", 2)); + assertInstanceOf(ColumnSelect.class, query); + assertEquals(ExpressionFactory.exp("count(*) > 2"), query.getHaving()); + assertEquals(10, query.getPageSize()); + assertTrue(descriptor.isColumnQuery()); + assertFalse(descriptor.isDbQuery()); + } - // parameters with no matching value are pruned from the qualifier - assertEquals(ExpressionFactory.exp("abc = 5"), query.getWhere()); + @Test + public void buildDbQuery() { + DataMap map = new DataMap(); + DbEntity entity = new DbEntity("ARTIST"); + map.addDbEntity(entity); + + SelectQueryDescriptor descriptor = descriptor("from db:ARTIST where NAME = 'a'"); + descriptor.setDataMap(map); + + FluentSelect query = descriptor.buildQuery(); + assertEquals("ARTIST", query.getDbEntityName()); + assertTrue(query.isFetchingDataRows()); + assertTrue(descriptor.isDbQuery()); + assertSame(entity, descriptor.getRoot()); } @Test - public void buildQueryWithoutParameters() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); + public void buildQueryInvalid() { + SelectQueryDescriptor descriptor = descriptor("from Artist where"); + descriptor.setName("q"); - assertNull(builder.buildQuery(Map.of("a", 5)).getWhere()); + CayenneRuntimeException e = assertThrows(CayenneRuntimeException.class, descriptor::buildQuery); + assertTrue(e.getUnlabeledMessage().startsWith("Query 'q' can't be parsed"), e.getUnlabeledMessage()); + } + + @Test + public void buildQueryEmpty() { + SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); + descriptor.setName("q"); + assertThrows(CayenneRuntimeException.class, descriptor::buildQuery); } @Test public void getQueryProperties() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - builder.setFetchLimit(5); - builder.setFetchOffset(2); - builder.setPageSize(10); - builder.setStatementFetchSize(6); - builder.setFetchingDataRows(true); - builder.setCacheStrategy(QueryCacheStrategy.SHARED_CACHE); - builder.setCacheGroup("g1"); - builder.setDistinct(true); - - ObjectSelect query = builder.buildQuery(); + SelectQueryDescriptor descriptor = descriptor("select distinct self from FakeRoot limit 5 offset 2"); + descriptor.setPageSize(10); + descriptor.setStatementFetchSize(6); + descriptor.setFetchingDataRows(true); + descriptor.setCacheStrategy(QueryCacheStrategy.SHARED_CACHE); + descriptor.setCacheGroup("g1"); + + FluentSelect query = descriptor.buildQuery(); assertEquals(5, query.getLimit()); assertEquals(2, query.getOffset()); assertEquals(10, query.getPageSize()); @@ -114,10 +155,7 @@ public void getQueryProperties() { @Test public void getQueryPropertiesDefaults() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - - ObjectSelect query = builder.buildQuery(); + FluentSelect query = descriptor("from FakeRoot").buildQuery(); assertEquals(0, query.getLimit()); assertEquals(0, query.getOffset()); assertEquals(0, query.getPageSize()); @@ -129,81 +167,200 @@ public void getQueryPropertiesDefaults() { } @Test - public void typedSetters() { - SelectQueryDescriptor builder = new SelectQueryDescriptor(); - builder.setRoot("FakeRoot"); - builder.setFetchLimit(5); - builder.setCacheStrategy(QueryCacheStrategy.LOCAL_CACHE); - builder.setCacheGroup("g1"); - builder.setFetchingDataRows(true); + public void getRoot() { + DataMap map = new DataMap(); + ObjEntity entity = new ObjEntity("A"); + map.addObjEntity(entity); - ObjectSelect query = builder.buildQuery(); - assertEquals(5, query.getLimit()); - assertEquals(QueryCacheStrategy.LOCAL_CACHE, query.getCacheStrategy()); - assertEquals("g1", query.getCacheGroup()); - assertTrue(query.isFetchingDataRows()); + SelectQueryDescriptor descriptor = descriptor("from A"); + assertEquals("A", descriptor.getRoot()); + + descriptor.setDataMap(map); + assertSame(entity, descriptor.getRoot()); + + // a String that can't be parsed has no root + descriptor.setQl("from B where"); + assertNull(descriptor.getRoot()); + assertFalse(descriptor.isDbQuery()); + + descriptor.setQl("from B"); + assertEquals("B", descriptor.getRoot()); } @Test - public void toQueryString() { - SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); - descriptor.setRoot(new ObjEntity("Artist")); - descriptor.setQualifier(ExpressionFactory.exp("artistName like $name")); - descriptor.addOrdering(new Ordering("artistName", SortOrder.DESCENDING_INSENSITIVE)); - descriptor.addOrdering(new Ordering("dateOfBirth", SortOrder.ASCENDING)); - descriptor.addPrefetch("paintings", PrefetchTreeNode.JOINT_PREFETCH_SEMANTICS); - descriptor.addPrefetch("paintings.gallery", PrefetchTreeNode.DISJOINT_PREFETCH_SEMANTICS); - descriptor.setFetchLimit(10); - descriptor.setFetchOffset(20); - descriptor.setDistinct(true); - - // properties outside the query String don't affect it - descriptor.setCacheStrategy(QueryCacheStrategy.SHARED_CACHE); - descriptor.setPageSize(5); + public void setRoot() { + assertThrows(UnsupportedOperationException.class, () -> new SelectQueryDescriptor().setRoot("A")); + } - assertEquals("select distinct self from Artist where artistName like $name " - + "order by artistName desc insensitive, dateOfBirth limit 10 offset 20 " - + "prefetch paintings joint, paintings.gallery disjoint", descriptor.toQueryString()); + @Test + public void invalidQl() { + SelectQueryDescriptor descriptor = descriptor("from Artist where"); + assertEquals("from Artist where", descriptor.getQl()); + assertFalse(descriptor.isValid()); + assertThrows(ExpressionException.class, descriptor::getParsedQuery); + + descriptor.setQl("from Artist"); + assertTrue(descriptor.isValid()); + assertNotNull(descriptor.getParsedQuery()); } @Test - public void toQueryStringNoRoot() { - SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); - descriptor.setQualifier(ExpressionFactory.exp("artistName like $name")); - assertNull(descriptor.toQueryString()); + public void getParameterNames() { + SelectQueryDescriptor descriptor = descriptor(""" + from Artist where name like $name and dateOfBirth in ($d1, $d2) + and exists (from Painting where price > $price and name like $name)"""); + assertEquals(List.of("name", "d1", "d2", "price"), List.copyOf(descriptor.getParameterNames())); + assertEquals(Set.of(), descriptor("from Artist").getParameterNames()); + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedPartGetters() { + SelectQueryDescriptor descriptor = descriptor(""" + select distinct self from Artist where artistName like $name + order by artistName desc insensitive, dateOfBirth + prefetch paintings joint, paintings.gallery disjoint"""); + + assertTrue(descriptor.isDistinct()); + assertEquals(ExpressionFactory.exp("artistName like $name"), descriptor.getQualifier()); + assertEquals(List.of( + new Ordering("artistName", SortOrder.DESCENDING_INSENSITIVE), + new Ordering("dateOfBirth", SortOrder.ASCENDING)), descriptor.getOrderings()); + assertEquals(Map.of( + "paintings", PrefetchTreeNode.JOINT_PREFETCH_SEMANTICS, + "paintings.gallery", PrefetchTreeNode.DISJOINT_PREFETCH_SEMANTICS), descriptor.getPrefetchesMap()); } @Test public void encodeAsXML() { - SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); + SelectQueryDescriptor descriptor = descriptor(""" + from Artist + where artistName = $name + order by artistName asc"""); descriptor.setName("q"); - descriptor.setRoot("Artist"); - descriptor.setQualifier(ExpressionFactory.exp("artistName = $name")); - descriptor.addOrdering(new Ordering("artistName", SortOrder.DESCENDING)); - descriptor.setFetchOffset(20); descriptor.setCacheStrategy(QueryCacheStrategy.LOCAL_CACHE); descriptor.setCacheGroup("g1 & g2"); - // the root, limit, offset and distinct are a part of the query String, and are not stored separately + // the String is stored verbatim assertEquals(""" - + """, encode(descriptor)); } @Test - public void encodeAsXMLNoRoot() { + public void encodeAsXMLInvalid() { + SelectQueryDescriptor descriptor = descriptor("from Artist where"); + descriptor.setName("q"); + + assertEquals(""" + + + + """, encode(descriptor)); + } + + @Test + public void encodeAsXMLEmpty() { SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); descriptor.setName("q"); - descriptor.setQualifier(ExpressionFactory.exp("artistName = $name")); assertEquals(""" """, encode(descriptor)); } + @Test + public void renameEntity() { + SelectQueryDescriptor descriptor = descriptor("select name from Artist\n order by name"); + descriptor.renameEntity("Artist", "Painter", false); + assertEquals("select name from Painter\n order by name", descriptor.getQl()); + assertEquals("Painter", descriptor.getParsedQuery().getEntityName()); + } + + @Test + public void renameEntityInSubquery() { + SelectQueryDescriptor descriptor = descriptor( + "from Painting where exists (from Artist where artist = enclosing(self))"); + descriptor.renameEntity("Artist", "Painter", false); + assertEquals("from Painting where exists (from Painter where artist = enclosing(self))", descriptor.getQl()); + } + + @Test + public void renameEntityWholeTokenOnly() { + String ql = "from Artist2 where fromArtist = 'Artist' and artist.name = 'x' and from = 1" + + " and exists (from Artist$1)"; + SelectQueryDescriptor descriptor = descriptor(ql); + descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity("artist", "Painter", false); + assertSame(ql, descriptor.getQl()); + } + + @Test + public void renameEntitySkipsStringLiterals() { + String literals = " where name = 'from Artist' or bio = \"from Artist \\\" from Artist\" or name = 'it\\'s'"; + SelectQueryDescriptor descriptor = descriptor("from Artist" + literals); + descriptor.renameEntity("Artist", "Painter", false); + assertEquals("from Painter" + literals, descriptor.getQl()); + + // a literal can span lines + descriptor = descriptor("from Artist where name = 'from\nArtist' and exists (from Artist)"); + descriptor.renameEntity("Artist", "Painter", false); + assertEquals("from Painter where name = 'from\nArtist' and exists (from Painter)", descriptor.getQl()); + + // an unterminated literal of a String that can't be parsed doesn't stop the rename + descriptor = descriptor("from Artist where name = 'from Artist"); + descriptor.renameEntity("Artist", "Painter", false); + assertEquals("from Painter where name = 'from Painter", descriptor.getQl()); + } + + @Test + public void renameDbEntity() { + SelectQueryDescriptor descriptor = descriptor("from db:ARTIST where NAME = 'a'"); + descriptor.renameEntity("ARTIST", "PAINTER", true); + assertEquals("from db:PAINTER where NAME = 'a'", descriptor.getQl()); + + // the grammar allows whitespace after the prefix + SelectQueryDescriptor spaced = descriptor("from db: ARTIST"); + spaced.renameEntity("ARTIST", "PAINTER", true); + assertEquals("from db: PAINTER", spaced.getQl()); + + // an ObjEntity of the same name is a different reference + descriptor.renameEntity("PAINTER", "ARTIST", false); + assertEquals("from db:PAINTER where NAME = 'a'", descriptor.getQl()); + + SelectQueryDescriptor objDescriptor = descriptor("from ARTIST where NAME = 'a'"); + objDescriptor.renameEntity("ARTIST", "PAINTER", true); + assertEquals("from ARTIST where NAME = 'a'", objDescriptor.getQl()); + } + + @Test + public void renameEntityNullAndSameName() { + SelectQueryDescriptor empty = new SelectQueryDescriptor(); + empty.renameEntity("Artist", "Painter", false); + assertNull(empty.getQl()); + + // a String that can't be parsed is renamed too, as it is kept to be fixed + SelectQueryDescriptor invalid = descriptor("from Artist where"); + invalid.renameEntity("Artist", "Painter", false); + assertEquals("from Painter where", invalid.getQl()); + + String ql = "from Artist"; + SelectQueryDescriptor same = descriptor(ql); + same.renameEntity("Artist", "Artist", false); + assertSame(ql, same.getQl()); + } + + private static SelectQueryDescriptor descriptor(String ql) { + SelectQueryDescriptor descriptor = new SelectQueryDescriptor(); + descriptor.setQl(ql); + return descriptor; + } + private static String encode(SelectQueryDescriptor descriptor) { StringWriter out = new StringWriter(); descriptor.encodeAsXML(new XMLEncoder(new PrintWriter(out)), new EmptyProjectNodeVisitor()); diff --git a/cayenne/src/test/java/org/apache/cayenne/project/xml/PrefetchTypeForSelectQueryHandlerTest.java b/cayenne/src/test/java/org/apache/cayenne/project/xml/PrefetchTypeForSelectQueryHandlerTest.java index 1a5a573cca..3420755acc 100644 --- a/cayenne/src/test/java/org/apache/cayenne/project/xml/PrefetchTypeForSelectQueryHandlerTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/project/xml/PrefetchTypeForSelectQueryHandlerTest.java @@ -21,6 +21,7 @@ import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.SelectQueryDescriptor; +import org.apache.cayenne.query.PrefetchTreeNode; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -39,11 +40,12 @@ public NamespaceAwareNestedTagHandler createHandler(NamespaceAwareNestedTagHandl }); SelectQueryDescriptor selectQueryDescriptor = (SelectQueryDescriptor) map.getQueryDescriptor("query"); - assertEquals(3, selectQueryDescriptor.getPrefetchesMap().size()); + PrefetchTreeNode prefetches = selectQueryDescriptor.getParsedQuery().getPrefetches(); + assertEquals(3, prefetches.nonPhantomNodes().size()); - assertEquals(1, (int) selectQueryDescriptor.getPrefetchesMap().get("paintings")); - assertEquals(2, (int) selectQueryDescriptor.getPrefetchesMap().get("paintings.artist")); - assertEquals(3, (int) selectQueryDescriptor.getPrefetchesMap().get("paintings.gallery")); + assertEquals(1, prefetches.getNode("paintings").getSemantics()); + assertEquals(2, prefetches.getNode("paintings.artist").getSemantics()); + assertEquals(3, prefetches.getNode("paintings.gallery").getSemantics()); } } diff --git a/cayenne/src/test/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandlerTest.java b/cayenne/src/test/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandlerTest.java index 87217f32bb..6ff2dcf592 100644 --- a/cayenne/src/test/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandlerTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/project/xml/SelectQueryDescriptorHandlerTest.java @@ -18,20 +18,15 @@ ****************************************************************/ package org.apache.cayenne.project.xml; -import org.apache.cayenne.ConfigurationException; import org.apache.cayenne.exp.ExpressionFactory; import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.query.Ordering; -import org.apache.cayenne.query.PrefetchTreeNode; import org.apache.cayenne.query.QueryCacheStrategy; -import org.apache.cayenne.query.SortOrder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; -import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -90,55 +85,26 @@ public void select() throws Exception { SelectQueryDescriptor descriptor = parseQuery(""" select distinct self from Artist where artistName like $name - order by artistName desc insensitive, dateOfBirth limit 10 offset 20 - prefetch paintings joint, paintings.gallery disjoint, exhibits disjointById, groups + order by artistName desc insensitive, dateOfBirth asc limit 10 offset 20 + prefetch paintings joint """); assertSame(entity, descriptor.getRoot()); - // parameters are preserved, to be bound when the query is executed - assertEquals(ExpressionFactory.exp("artistName like $name"), descriptor.getQualifier()); - - assertEquals(List.of( - new Ordering("artistName", SortOrder.DESCENDING_INSENSITIVE), - new Ordering("dateOfBirth", SortOrder.ASCENDING)), - descriptor.getOrderings()); - - assertEquals(Map.of( - "paintings", PrefetchTreeNode.JOINT_PREFETCH_SEMANTICS, - "paintings.gallery", PrefetchTreeNode.DISJOINT_PREFETCH_SEMANTICS, - "exhibits", PrefetchTreeNode.DISJOINT_BY_ID_PREFETCH_SEMANTICS, - "groups", PrefetchTreeNode.UNDEFINED_SEMANTICS), - descriptor.getPrefetchesMap()); - - assertEquals(10, descriptor.getFetchLimit()); - assertEquals(20, descriptor.getFetchOffset()); - assertTrue(descriptor.isDistinct()); - } - - @Test - public void selectMinimal() throws Exception { - ObjEntity entity = new ObjEntity("Artist"); - map.addObjEntity(entity); - - SelectQueryDescriptor descriptor = parseQuery(""" - - from Artist - - """); + // the String is kept as is, with its formatting + assertEquals(""" + select distinct self from Artist where artistName like $name + order by artistName desc insensitive, dateOfBirth asc limit 10 offset 20 + prefetch paintings joint""", descriptor.getQl()); - assertSame(entity, descriptor.getRoot()); - assertNull(descriptor.getQualifier()); - assertTrue(descriptor.getOrderings().isEmpty()); - assertTrue(descriptor.getPrefetchesMap().isEmpty()); - assertEquals(0, descriptor.getFetchLimit()); - assertEquals(0, descriptor.getFetchOffset()); - assertFalse(descriptor.isDistinct()); + // parameters are preserved, to be bound when the query is executed + assertEquals(ExpressionFactory.exp("artistName like $name"), descriptor.getParsedQuery().getWhere()); + assertEquals(10, descriptor.getParsedQuery().getLimit()); } @Test - public void selectWithoutRoot() throws Exception { + public void selectWithoutQl() throws Exception { SelectQueryDescriptor descriptor = parseQuery(""" @@ -147,6 +113,7 @@ public void selectWithoutRoot() throws Exception { // unlike the other queries, a select query is never rooted in a DataMap assertNull(descriptor.getRoot()); + assertNull(descriptor.getQl()); } @Test @@ -157,21 +124,33 @@ public void selectWithUnknownRoot() throws Exception { """); - // an entity that is not in the DataMap is kept by name, so that the query is not lost on save + // an entity that is not in the DataMap is kept by name assertEquals("Artist", descriptor.getRoot()); - assertEquals("from Artist where artistName = \"a\"", descriptor.toQueryString()); } @Test - public void selectUnsupported() { - // TODO: column queries, "having" and DbEntity roots are not supported by the descriptor yet + public void selectColumnsHavingAndDbRoot() throws Exception { for (String ql : List.of( "select artistName from Artist", "from Artist having count(paintings) > 1", - "from db:ARTIST", - "from Artist where")) { - assertThrows(ConfigurationException.class, - () -> parseQuery("" + ql + ""), ql); + "from db:ARTIST")) { + SelectQueryDescriptor descriptor = parseQuery("" + ql + ""); + assertEquals(ql, descriptor.getQl()); + assertTrue(descriptor.isValid(), ql); + map.removeQueryDescriptor("q"); } } + + @Test + public void selectInvalid() throws Exception { + // a String that can't be parsed is loaded, so that it can be fixed in the Modeler + SelectQueryDescriptor descriptor = parseQuery(""" + + from Artist where + + """); + + assertEquals("from Artist where", descriptor.getQl()); + assertFalse(descriptor.isValid()); + } } diff --git a/cayenne/src/test/java/org/apache/cayenne/project/xml/XMLDataMapLoaderTest.java b/cayenne/src/test/java/org/apache/cayenne/project/xml/XMLDataMapLoaderTest.java index 99f444d4eb..74ac997c0a 100644 --- a/cayenne/src/test/java/org/apache/cayenne/project/xml/XMLDataMapLoaderTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/project/xml/XMLDataMapLoaderTest.java @@ -92,7 +92,7 @@ public void loadOldVersionWithContent() { assertEquals("org.apache.cayenne.GenericPersistentObject", map.getObjEntity("Artist").getClassName()); SelectQueryDescriptor select = (SelectQueryDescriptor) map.getQueryDescriptor("ArtistQuery"); - assertEquals("from Artist where artistName = \"a\" limit 5", select.toQueryString()); + assertEquals("from Artist where artistName = \"a\" limit 5", select.getQl()); assertEquals(10, select.getPageSize()); assertEquals("g1 & g2", select.getCacheGroup()); diff --git a/cayenne/src/test/java/org/apache/cayenne/query/MappedQueryIT.java b/cayenne/src/test/java/org/apache/cayenne/query/MappedQueryIT.java index 92c29bc686..583c5fd77c 100644 --- a/cayenne/src/test/java/org/apache/cayenne/query/MappedQueryIT.java +++ b/cayenne/src/test/java/org/apache/cayenne/query/MappedQueryIT.java @@ -86,6 +86,47 @@ public void butchIterator() throws Exception { } + @Test + public void columnQuery() throws Exception { + createArtistsDataSet(); + + List names = MappedSelect.query("ArtistNamesQuery").param("name", "artist1%").select(env.context()); + assertEquals(List.of("artist1", "artist10", "artist11", "artist12", "artist13", "artist14", "artist15", + "artist16", "artist17", "artist18", "artist19"), names); + } + + @Test + public void dbQuery() throws Exception { + createArtistsDataSet(); + + List rows = MappedSelect.query("ArtistRowsQuery", DataRow.class) + .param("name", "artist3") + .select(env.context()); + assertEquals(1, rows.size()); + assertEquals(3L, ((Number) rows.get(0).get("ARTIST_ID")).longValue()); + } + + @Test + public void subqueryParameters() throws Exception { + TableHelper tArtist = env.table("ARTIST", "ARTIST_ID", "ARTIST_NAME"); + tArtist.insert(1, "artist1"); + tArtist.insert(2, "artist2"); + tArtist.insert(3, "artist3"); + + TableHelper tPainting = env.table("PAINTING", "PAINTING_ID", "PAINTING_TITLE", "ARTIST_ID", "ESTIMATED_PRICE"); + tPainting.insert(1, "p1", 1, 100); + tPainting.insert(2, "p2", 2, 2000); + + List expensive = MappedSelect.query("ArtistsWithPaintingsQuery", Artist.class) + .param("price", 1000) + .select(env.context()); + assertEquals(List.of("artist2"), expensive.stream().map(Artist::getArtistName).toList()); + + // with the parameter missing, its condition is pruned from the subquery + List any = MappedSelect.query("ArtistsWithPaintingsQuery", Artist.class).select(env.context()); + assertEquals(List.of("artist1", "artist2"), any.stream().map(Artist::getArtistName).toList()); + } + @Test public void sqlTemplateSelect() throws Exception { createArtistsDataSet(); diff --git a/cayenne/src/test/resources/testmap.map.xml b/cayenne/src/test/resources/testmap.map.xml index 532cc0080f..25e4b9e4bf 100644 --- a/cayenne/src/test/resources/testmap.map.xml +++ b/cayenne/src/test/resources/testmap.map.xml @@ -254,6 +254,19 @@ + + + + + + + + $price) +order by artistName]]> + diff --git a/docs/cayenne-guide/src/docs/asciidoc/_cayenne-guide/queries/mapped.adoc b/docs/cayenne-guide/src/docs/asciidoc/_cayenne-guide/queries/mapped.adoc index 704caf605c..7a5308e3ff 100644 --- a/docs/cayenne-guide/src/docs/asciidoc/_cayenne-guide/queries/mapped.adoc +++ b/docs/cayenne-guide/src/docs/asciidoc/_cayenne-guide/queries/mapped.adoc @@ -19,6 +19,11 @@ The actual stored query can be ObjectSelect, SQLTemplate or ProcedureQuery. Difference between `MappedSelect` and `MappedExec` is (as reflected in their names) whether underlying query intended to select data or just to perform some generic SQL code. +An ObjectSelect stored in the DataMap is defined by a String in the <>, so it can be any +query that the language supports, including the column queries and the queries rooted in a DbEntity. Its "$name" +parameters are bound by name when the query is executed. A condition with a parameter that has no value is dropped +from the query, so the same mapped query can be used both with and without its optional parameters. + Here is example of how to use `MappedSelect`: [source,java,indent=0] From 0517dec390b810b81955d7d6c964fdf3bf3838a4 Mon Sep 17 00:00:00 2001 From: Nikita Timofeev Date: Fri, 23 Oct 2026 20:26:42 +0400 Subject: [PATCH 2/2] CAY-3056 Switch DataMap queries to the new query parser --- .../org/apache/cayenne/gen/DataMapUtils.java | 218 ++++++++++-------- .../org/apache/cayenne/gen/ImportUtils.java | 12 +- .../org/apache/cayenne/gen/StringUtils.java | 16 +- .../templates/v4_1/datamap-singleclass.vm | 10 +- .../templates/v4_1/datamap-superclass.vm | 10 +- .../gen/BaseTemplatesGenerationTest.java | 3 +- .../apache/cayenne/gen/DataMapUtilsTest.java | 96 +++++++- .../apache/cayenne/gen/ImportUtilsTest.java | 11 + .../apache/cayenne/gen/StringUtilsTest.java | 10 + .../_auto/_SelectQueryResultTypes.java | 10 + .../query/selectquery/ObjectSelectView.java | 23 +- .../query/selectquery/QLStatusLine.java | 28 +-- .../validation/SelectQueryPaths.java | 213 ++++++++++------- .../java/org/apache/cayenne/map/DataMap.java | 13 +- .../cayenne/map/SelectQueryDescriptor.java | 68 +++++- .../org/apache/cayenne/map/DataMapTest.java | 55 +++++ .../map/SelectQueryDescriptorTest.java | 64 +++-- 17 files changed, 609 insertions(+), 251 deletions(-) diff --git a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java index 215bc13f39..e25e500fbb 100644 --- a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java +++ b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/DataMapUtils.java @@ -20,14 +20,12 @@ package org.apache.cayenne.gen; import org.apache.cayenne.DataRow; -import org.apache.cayenne.exp.EnclosingObjectExp; import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ExpressionParameter; +import org.apache.cayenne.exp.InExp; import org.apache.cayenne.exp.ListExp; +import org.apache.cayenne.exp.NotInExp; import org.apache.cayenne.exp.ObjPathExp; -import org.apache.cayenne.exp.SubqueryExp; -import org.apache.cayenne.exp.property.Property; import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.DbEntity; import org.apache.cayenne.map.ObjAttribute; @@ -36,15 +34,21 @@ import org.apache.cayenne.map.QueryDescriptor; import org.apache.cayenne.map.SelectQueryDescriptor; import org.apache.cayenne.projecttools.validation.SelectQueryPaths; -import org.apache.cayenne.query.ColumnSelect; +import org.apache.cayenne.projecttools.validation.SelectQueryPaths.Clause; +import org.apache.cayenne.projecttools.validation.SelectQueryPaths.PathRef; import org.apache.cayenne.query.FluentSelect; import org.apache.cayenne.util.Util; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; -import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; + +import javax.lang.model.SourceVersion; /** * Attributes and Methods for working with Queries. @@ -55,6 +59,13 @@ public class DataMapUtils { private static final String OBJECT_TYPE = Object.class.getName(); + // the names of the variables of the generated query methods + private static final Set RESERVED_NAMES = Set.of("context", "query"); + + // the parameters are requested many times per query by the templates, the identity of the parsed query detects a + // change of the query String + private final Map parameters = new IdentityHashMap<>(); + /** * Return valid method name based on query name (replace all illegal * characters with underscore '_'). @@ -67,13 +78,33 @@ public String getQueryMethodName(QueryDescriptor query) { } /** - * Get all parameter names used in the query, in the order of their first occurrence. + * Get all parameter names used in the query, in the order of their first occurrence. The names are the Java names + * of the parameters, see {@link QueryParameter#javaName()}. * * @param query select query descriptor * @return Parameter names. + * @deprecated since 5.0, use {@link #getParameters(SelectQueryDescriptor)}, that provides both the names to bind + * the parameters by and their Java names */ + @Deprecated(since = "5.0") public Collection getParameterNames(SelectQueryDescriptor query) { - return parameterTypes(query).keySet(); + return getParameters(query).stream().map(QueryParameter::javaName).toList(); + } + + /** + * Returns the "$name" parameters of the query, in the order of their first occurrence, each with the name to bind + * it by, a unique Java identifier to use for it in the generated code, and its type. + * + * @since 5.0 + */ + public List getParameters(SelectQueryDescriptor query) { + FluentSelect parsed = query.isValid() ? query.getParsedQuery() : null; + CachedParameters cached = parameters.get(query); + if (cached == null || cached.parsed() != parsed) { + cached = new CachedParameters(parsed, parsed != null ? computeParameters(query) : List.of()); + parameters.put(query, cached); + } + return cached.parameters(); } /** @@ -89,18 +120,25 @@ public Boolean isValidParameterNames(SelectQueryDescriptor query) { } public boolean hasParameters(SelectQueryDescriptor query) { - return !getParameterNames(query).isEmpty(); + return !getParameters(query).isEmpty(); } /** * Get type of parameter for given name. * * @param query descriptor - * @param name parameter name + * @param name the Java name of the parameter, see {@link QueryParameter#javaName()} * @return Parameter type. + * @deprecated since 5.0, use {@link #getParameters(SelectQueryDescriptor)} */ + @Deprecated(since = "5.0") public String getParameterType(SelectQueryDescriptor query, String name) { - return parameterTypes(query).get(name); + for (QueryParameter parameter : getParameters(query)) { + if (parameter.javaName().equals(name)) { + return parameter.type(); + } + } + return null; } /** @@ -110,124 +148,116 @@ public String getParameterType(SelectQueryDescriptor query, String name) { * @since 5.0 */ public String getResultType(SelectQueryDescriptor query) { - FluentSelect parsed = query.getParsedQuery(); - if (parsed instanceof ColumnSelect columnSelect) { - return columnSelect.getColumns().size() == 1 ? OBJECT_TYPE : Object[].class.getCanonicalName(); - } - - if (query.getRoot() instanceof ObjEntity entity && !query.isFetchingDataRows()) { - return entity.getClassName(); - } - - return DataRow.class.getName(); + return switch (query.getResultKind()) { + case ENTITY -> ((ObjEntity) query.getRoot()).getClassName(); + case SCALAR -> OBJECT_TYPE; + case ARRAY -> Object[].class.getCanonicalName(); + case DATA_ROW -> DataRow.class.getName(); + // a query with an unmapped root can't be run, but gets a type anyway + case null -> DataRow.class.getName(); + }; } - // parameter types by the names of the parameters, in the order of their first occurrence - private Map parameterTypes(SelectQueryDescriptor query) { - if (!query.isValid()) { - return Map.of(); - } - - Map types = new HashMap<>(); - collectParameterTypes(query.getDataMap(), query.getParsedQuery(), types); + private List computeParameters(SelectQueryDescriptor query) { + Map types = parameterTypes(query.getDataMap(), query.getParsedQuery()); - Map ordered = new LinkedHashMap<>(); + // the Java names must be unique and must not clash with the keywords and the variables of the generated method + Set javaNames = new HashSet<>(RESERVED_NAMES); + List result = new ArrayList<>(); for (String name : query.getParameterNames()) { - ordered.put(Util.underscoredToJava(name, false), types.getOrDefault(name, OBJECT_TYPE)); + String javaName = uniqueJavaName(name, javaNames); + result.add(new QueryParameter(name, javaName, types.getOrDefault(name, OBJECT_TYPE))); } - return ordered; + return List.copyOf(result); } - private void collectParameterTypes(DataMap dataMap, FluentSelect query, Map types) { - // the type of a parameter can only be inferred from an ObjEntity attribute or relationship - ObjEntity entity = query.getEntityName() != null && dataMap != null - ? dataMap.getObjEntity(query.getEntityName()) - : null; - - // the aliases of the columns can be used in place of the paths elsewhere in the query - Map aliasTypes = new HashMap<>(); - if (query.getColumns() != null) { - for (Property column : query.getColumns()) { - if (column.getAlias() != null) { - aliasTypes.put(column.getAlias(), column.getExpression() instanceof ObjPathExp pathExp - ? pathType(entity, pathExp) - : OBJECT_TYPE); - } - } + private static String uniqueJavaName(String name, Set taken) { + String base = Util.underscoredToJava(name, false); + if (base.isEmpty() || !Character.isJavaIdentifierStart(base.charAt(0))) { + base = "p" + base; } - Scope scope = new Scope(dataMap, entity, aliasTypes); - collectParameterTypes(scope, query.getWhere(), types); - collectParameterTypes(scope, query.getHaving(), types); + String javaName = base; + for (int i = 2; SourceVersion.isKeyword(javaName) || !taken.add(javaName); i++) { + javaName = base + i; + } + return javaName; } - private void collectParameterTypes(Scope scope, Expression expression, Map types) { - if (expression == null || expression instanceof EnclosingObjectExp) { - return; + private static Map parameterTypes(DataMap dataMap, FluentSelect query) { + Map types = new HashMap<>(); + if (dataMap == null) { + return types; } - if (expression instanceof SubqueryExp subquery) { - if (subquery.getQuery() != null) { - collectParameterTypes(scope.dataMap(), subquery.getQuery(), types); + // a parameter takes the type of a path compared to it, i.e. of the last path among the operands of the + // expression that has the parameter as its operand + Map pathTypes = new IdentityHashMap<>(); + SelectQueryPaths.walk(dataMap, query, path -> { + if ((path.clause() == Clause.WHERE || path.clause() == Clause.HAVING) + && path.path() instanceof ObjPathExp && path.parent() != null) { + pathTypes.put(path.parent(), pathType(path)); } - return; - } + }); - // a parameter takes the type of a path compared to it - String typeName = OBJECT_TYPE; - for (int i = 0; i < expression.getOperandCount(); i++) { - if (expression.getOperand(i) instanceof ObjPathExp pathExp) { - typeName = scope.aliasTypes().getOrDefault(pathExp.getPath().value(), - pathType(scope.entity(), pathExp)); - } - } + pathTypes.forEach((expression, typeName) -> { + // the right-hand side parameter of "in" is a collection of the values of the path + boolean in = expression instanceof InExp || expression instanceof NotInExp; - for (int i = 0; i < expression.getOperandCount(); i++) { - Object operand = expression.getOperand(i); - switch (operand) { - case ExpressionParameter parameter -> { - types.merge(parameter.getName(), typeName, DataMapUtils::moreSpecific); - } - case ListExp list -> { - for (Object value : (Object[]) list.getOperand(0)) { - if (value instanceof ExpressionParameter parameter) { - types.merge(parameter.getName(), typeName, DataMapUtils::moreSpecific); + for (int i = 0; i < expression.getOperandCount(); i++) { + switch (expression.getOperand(i)) { + case ExpressionParameter parameter -> { + String parameterType = in && i == 1 ? collectionType(typeName) : typeName; + types.merge(parameter.getName(), parameterType, DataMapUtils::moreSpecific); + } + case ListExp list -> { + for (Object value : (Object[]) list.getOperand(0)) { + if (value instanceof ExpressionParameter parameter) { + types.merge(parameter.getName(), typeName, DataMapUtils::moreSpecific); + } } } - } - case Expression child -> collectParameterTypes(scope, child, types); - case null, default -> { + case null, default -> { + } } } - } + }); + return types; } // the type of the attribute or relationship at the end of a path, or Object if the path can't be resolved. An // invalid path is a problem of the query, reported by the validator, and not of the type inference - private static String pathType(ObjEntity entity, ObjPathExp pathExp) { - if (entity == null) { - return OBJECT_TYPE; - } - - List components; - try { - components = entity.resolvePath(pathExp.getExpandedPath()); - } catch (ExpressionException e) { + private static String pathType(PathRef path) { + if (path.components() == null) { return OBJECT_TYPE; } - return switch (components.getLast()) { + return switch (path.components().getLast()) { case ObjAttribute attribute -> attribute.getType(); case ObjRelationship relationship -> relationship.getTargetEntity().getClassName(); default -> OBJECT_TYPE; }; } - // the root entity of a query (null if unknown) and the types of its column aliases - private record Scope(DataMap dataMap, ObjEntity entity, Map aliasTypes) { + private static String collectionType(String elementType) { + return Collection.class.getName() + "<" + elementType + ">"; } private static String moreSpecific(String type1, String type2) { - return OBJECT_TYPE.equals(type1) ? type2 : type1; + if (OBJECT_TYPE.equals(type1) || type1.equals(type2)) { + return type2; + } + return OBJECT_TYPE.equals(type2) ? type1 : OBJECT_TYPE; + } + + private record CachedParameters(FluentSelect parsed, List parameters) { + } + + /** + * A "$name" parameter of a select query. + * + * @since 5.0 + */ + public record QueryParameter(String name, String javaName, String type) { } } diff --git a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/ImportUtils.java b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/ImportUtils.java index 42fe98818c..852de36623 100644 --- a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/ImportUtils.java +++ b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/ImportUtils.java @@ -28,6 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -108,9 +109,18 @@ public void addReservedType(String typeName) { * org.apache.cayenne.GenericPersistentObject. * * @param typeName - * FQ data type class name. + * FQ data type class name. For a parameterized type, like + * "java.util.List<java.lang.String>", all the classes in it are registered. */ public void addType(String typeName) { + if (typeName != null && typeName.indexOf('<') >= 0) { + Matcher matcher = StringUtils.CLASS_NAME.matcher(typeName); + while (matcher.find()) { + addType(matcher.group()); + } + return; + } + if (!canRegisterType(typeName)) { return; } diff --git a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/StringUtils.java b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/StringUtils.java index fc9cd71551..78e911307c 100644 --- a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/StringUtils.java +++ b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/StringUtils.java @@ -22,11 +22,17 @@ import org.apache.cayenne.projecttools.validation.NameValidator; import org.apache.cayenne.util.Util; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * Methods for mangling strings. */ public class StringUtils { + // a simple or a fully qualified class name within a parameterized type name, like "java.util.List" + static final Pattern CLASS_NAME = Pattern.compile("[\\p{javaJavaIdentifierPart}.]+"); + private static StringUtils sharedInstance; public static StringUtils getInstance() { @@ -51,12 +57,18 @@ public String formatVariableName(String variableName) { } /** - * Removes package name, leaving base name. + * Removes package name, leaving base name. For a parameterized type, like "java.util.List<java.lang.String>", + * removes the package names of all the classes in it. * * @since 1.2 */ public String stripPackageName(String fullyQualifiedClassName) { - return Util.stripPackageName(fullyQualifiedClassName); + if (fullyQualifiedClassName == null || fullyQualifiedClassName.indexOf('<') < 0) { + return Util.stripPackageName(fullyQualifiedClassName); + } + + return CLASS_NAME.matcher(fullyQualifiedClassName) + .replaceAll(m -> Matcher.quoteReplacement(Util.stripPackageName(m.group()))); } /** diff --git a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm index 308a406076..ee00795a1f 100644 --- a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm +++ b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-singleclass.vm @@ -42,8 +42,8 @@ ${importUtils.addType('org.apache.cayenne.query.MappedSelect')}## #foreach( $selectQuery in ${object.SelectQueries}) #if(${dataMapUtils.isValidParameterNames($selectQuery)}) ${importUtils.addType(${dataMapUtils.getResultType($selectQuery)})}## -#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) -${importUtils.addType(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})}## +#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}) +${importUtils.addType(${parameter.type()})}## #end## #end## #end## @@ -83,10 +83,10 @@ public class ${subClassName} { #foreach( $selectQuery in ${object.SelectQueries}) #if(${dataMapUtils.isValidParameterNames($selectQuery)}) #set($rootClass = ${stringUtils.stripPackageName(${dataMapUtils.getResultType($selectQuery)})}) - public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}), ${stringUtils.stripPackageName(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})} ${parameter}#end) { + public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}), ${stringUtils.stripPackageName(${parameter.type()})} ${parameter.javaName()}#end) { MappedSelect<${rootClass}> query = MappedSelect.query(${stringUtils.capitalizedAsConstant(${selectQuery.Name})}_QUERYNAME, ${rootClass}.class); -#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) - query.param("${parameter}", ${parameter}); +#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}) + query.param("${parameter.name()}", ${parameter.javaName()}); #end return query.select(context); } diff --git a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm index a6e3e65ba2..b0058826f9 100644 --- a/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm +++ b/cayenne-cgen/src/main/resources/templates/v4_1/datamap-superclass.vm @@ -41,8 +41,8 @@ ${importUtils.addType('org.apache.cayenne.query.MappedSelect')}## #foreach( $selectQuery in ${object.SelectQueries}) #if(${dataMapUtils.isValidParameterNames($selectQuery)}) ${importUtils.addType(${dataMapUtils.getResultType($selectQuery)})}## -#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) -${importUtils.addType(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})}## +#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}) +${importUtils.addType(${parameter.type()})}## #end## #end## #end## @@ -72,10 +72,10 @@ public class ${superClassName} { #if(${dataMapUtils.isValidParameterNames($selectQuery)}) #set($rootClass = ${stringUtils.stripPackageName(${dataMapUtils.getResultType($selectQuery)})}) - public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}), ${stringUtils.stripPackageName(${dataMapUtils.getParameterType(${selectQuery}, ${parameter})})} ${parameter}#end) { + public List<$rootClass> perform${dataMapUtils.getQueryMethodName(${selectQuery})}(ObjectContext context#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}), ${stringUtils.stripPackageName(${parameter.type()})} ${parameter.javaName()}#end) { MappedSelect<${rootClass}> query = MappedSelect.query(${stringUtils.capitalizedAsConstant(${selectQuery.Name})}_QUERYNAME, ${rootClass}.class); -#foreach( $parameter in ${dataMapUtils.getParameterNames(${selectQuery})}) - query.param("${parameter}", ${parameter}); +#foreach( $parameter in ${dataMapUtils.getParameters(${selectQuery})}) + query.param("${parameter.name()}", ${parameter.javaName()}); #end return query.select(context); } diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java index 1de1364273..07877e4e2e 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/BaseTemplatesGenerationTest.java @@ -86,7 +86,8 @@ public void selectQueryResultTypes() throws Exception { execute(new DataMapArtifact(dataMap, List.of( selectQuery("names", "select name from Test where name like $name"), selectQuery("counts", "select name, count(*) from Test"), - selectQuery("rows", "from db:TEST where testKey = $key")))); + selectQuery("rows", "from db:TEST where testKey = $key"), + selectQuery("filtered", "from Test where name in $name_list or name like $name_pattern")))); } private void createTestEntity() { diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java index 5bc0b5cf8f..8a440a87c2 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/DataMapUtilsTest.java @@ -20,7 +20,11 @@ package org.apache.cayenne.gen; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.cayenne.gen.DataMapUtils.QueryParameter; import org.apache.cayenne.map.DataMap; import org.apache.cayenne.map.DbAttribute; import org.apache.cayenne.map.DbEntity; @@ -33,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; public class DataMapUtilsTest { @@ -67,25 +72,76 @@ public void setUp() { } @Test - public void getParameterNames() { + public void getParameters() { SelectQueryDescriptor query = query(""" from Artist where name like $name and born in ($from_date, $to_date) and name != $name"""); - assertEquals(List.of("name", "fromDate", "toDate"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertEquals(List.of( + new QueryParameter("name", "name", "java.lang.String"), + new QueryParameter("from_date", "fromDate", "java.time.LocalDate"), + new QueryParameter("to_date", "toDate", "java.time.LocalDate")), dataMapUtils.getParameters(query)); assertTrue(dataMapUtils.hasParameters(query)); assertFalse(dataMapUtils.hasParameters(query("from Artist"))); } + @Test + public void getParametersCached() { + SelectQueryDescriptor query = query("from Artist where name = $name"); + List parameters = dataMapUtils.getParameters(query); + assertSame(parameters, dataMapUtils.getParameters(query)); + + // a change of the query String is picked up + query.setQl("from Artist where born = $born"); + assertEquals(List.of(new QueryParameter("born", "born", "java.time.LocalDate")), + dataMapUtils.getParameters(query)); + } + + @Test + public void getParametersUniqueJavaNames() { + SelectQueryDescriptor query = query(""" + from Artist where name = $a_b or name = $aB or name = $context or born = $query or born = $class"""); + + assertEquals(List.of("a_b", "aB", "context", "query", "class"), names(query, QueryParameter::name)); + assertEquals(List.of("aB", "aB2", "context2", "query2", "class2"), names(query, QueryParameter::javaName)); + } + @Test public void getParameterType() { SelectQueryDescriptor query = query(""" from Painting where price > $price and artist = $artist and $flag = true and exists (from Artist where born < $born)"""); - assertEquals("java.math.BigDecimal", dataMapUtils.getParameterType(query, "price")); - assertEquals("org.example.Artist", dataMapUtils.getParameterType(query, "artist")); - assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "flag")); - assertEquals("java.time.LocalDate", dataMapUtils.getParameterType(query, "born")); + assertEquals(Map.of( + "price", "java.math.BigDecimal", + "artist", "org.example.Artist", + "flag", "java.lang.Object", + "born", "java.time.LocalDate"), types(query)); + } + + @Test + public void getParameterTypeOfIn() { + SelectQueryDescriptor query = query(""" + from Artist where name in $names and born not in $dates and name in ($a, $b) and $c in ('x')"""); + + assertEquals(Map.of( + "names", "java.util.Collection", + "dates", "java.util.Collection", + "a", "java.lang.String", + "b", "java.lang.String", + "c", "java.lang.Object"), types(query)); + } + + @Test + public void getParameterTypeOfConflictingUses() { + SelectQueryDescriptor query = query(""" + from Artist where name = $x and born = $x and name = $y and $y = 1 and $z = 1 and born = $z + and name in $w and name = $w"""); + + assertEquals(Map.of( + "x", "java.lang.Object", + "y", "java.lang.String", + "z", "java.time.LocalDate", + "w", "java.lang.Object"), types(query)); } @Test @@ -94,9 +150,8 @@ public void getParameterTypeOfAlias() { select name as n, count(*) as c from Artist having c > $min and n like $pattern"""); assertTrue(dataMapUtils.isValidParameterNames(query)); - assertEquals(List.of("min", "pattern"), List.copyOf(dataMapUtils.getParameterNames(query))); - assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "min")); - assertEquals("java.lang.String", dataMapUtils.getParameterType(query, "pattern")); + assertEquals(List.of("min", "pattern"), names(query, QueryParameter::javaName)); + assertEquals(Map.of("min", "java.lang.Object", "pattern", "java.lang.String"), types(query)); } @Test @@ -105,10 +160,18 @@ public void getParameterTypeOfInvalidPath() { SelectQueryDescriptor query = query("from Artist where nme = $name and born = $born"); assertFalse(dataMapUtils.isValidParameterNames(query)); - assertEquals(List.of("name", "born"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertEquals(List.of("name", "born"), names(query, QueryParameter::javaName)); assertTrue(dataMapUtils.hasParameters(query)); - assertEquals("java.lang.Object", dataMapUtils.getParameterType(query, "name")); - assertEquals("java.time.LocalDate", dataMapUtils.getParameterType(query, "born")); + assertEquals(Map.of("name", "java.lang.Object", "born", "java.time.LocalDate"), types(query)); + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedParameterMethods() { + SelectQueryDescriptor query = query("from Artist where born = $from_date"); + + assertEquals(List.of("fromDate"), List.copyOf(dataMapUtils.getParameterNames(query))); + assertEquals("java.time.LocalDate", dataMapUtils.getParameterType(query, "fromDate")); } @Test @@ -139,6 +202,15 @@ public void getResultType() { assertEquals("org.apache.cayenne.DataRow", dataMapUtils.getResultType(dataRows)); } + private List names(SelectQueryDescriptor query, Function name) { + return dataMapUtils.getParameters(query).stream().map(name).toList(); + } + + private Map types(SelectQueryDescriptor query) { + return dataMapUtils.getParameters(query).stream() + .collect(Collectors.toMap(QueryParameter::javaName, QueryParameter::type)); + } + private SelectQueryDescriptor query(String ql) { SelectQueryDescriptor query = new SelectQueryDescriptor(); query.setName("q"); diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/ImportUtilsTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/ImportUtilsTest.java index b16fb75844..41b3eb49ba 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/ImportUtilsTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/ImportUtilsTest.java @@ -228,4 +228,15 @@ public void javaLangTypeFormatJavaTypeWithoutAddTypeGeneratesCorrectFQNs() assertEquals(baseType, importUtils.formatJavaType(fullyQualifiedType)); } + + @Test + public void addParameterizedType() { + importUtils.addType("java.util.Map"); + + String generatedStatements = importUtils.generate(); + assertTrue(generatedStatements.contains("import java.util.Map;"), generatedStatements); + assertTrue(generatedStatements.contains("import org.myPackage.MyType;"), generatedStatements); + assertFalse(generatedStatements.contains("java.lang"), generatedStatements); + assertFalse(generatedStatements.contains("<"), generatedStatements); + } } diff --git a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/StringUtilsTest.java b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/StringUtilsTest.java index 6af78a4dca..25e31c1646 100644 --- a/cayenne-cgen/src/test/java/org/apache/cayenne/gen/StringUtilsTest.java +++ b/cayenne-cgen/src/test/java/org/apache/cayenne/gen/StringUtilsTest.java @@ -121,4 +121,14 @@ public void replaceWildcardInStringWithString() throws Exception { assertEquals("EditEntityReport.vm", stringUtils.replaceWildcardInStringWithString("*", "Edit*Report.vm", "Entity")); assertEquals("Entity", stringUtils.replaceWildcardInStringWithString("*", "*", "Entity")); } + + @Test + public void stripPackageName() { + assertEquals("String", stringUtils.stripPackageName("java.lang.String")); + assertEquals("Object[]", stringUtils.stripPackageName("java.lang.Object[]")); + assertEquals("Test", stringUtils.stripPackageName("Test")); + assertEquals("Collection", stringUtils.stripPackageName("java.util.Collection")); + assertEquals("Map>", + stringUtils.stripPackageName("java.util.Map>")); + } } diff --git a/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java b/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java index 8a223ff253..eae48fc2ae 100644 --- a/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java +++ b/cayenne-cgen/src/test/resources/templateTest/_auto/_SelectQueryResultTypes.java @@ -1,5 +1,6 @@ package test.auto; +import java.util.Collection; import java.util.List; import org.apache.cayenne.DataRow; @@ -20,6 +21,8 @@ public class _SelectQueryResultTypes { public static final String ROWS_QUERYNAME = "rows"; + public static final String FILTERED_QUERYNAME = "filtered"; + public List performNames(ObjectContext context, String name) { MappedSelect query = MappedSelect.query(NAMES_QUERYNAME, Object.class); query.param("name", name); @@ -37,4 +40,11 @@ public List performRows(ObjectContext context, Object key) { return query.select(context); } + public List performFiltered(ObjectContext context, Collection nameList, String namePattern) { + MappedSelect query = MappedSelect.query(FILTERED_QUERYNAME, Test.class); + query.param("name_list", nameList); + query.param("name_pattern", namePattern); + return query.select(context); + } + } diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/ObjectSelectView.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/ObjectSelectView.java index 157e8efd6d..b7594564f4 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/ObjectSelectView.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/ObjectSelectView.java @@ -55,6 +55,9 @@ public class ObjectSelectView extends BaseQueryMainTab { private final ObjectQueryPropertiesPanel properties; private final CMUndoableTextField comment; + // the query that the path browser was last populated for + private SelectQueryDescriptor browserQuery; + public ObjectSelectView(ProjectSession session) { super(session); this.name = new CMUndoableTextField(app.getUndoManager()); @@ -129,6 +132,7 @@ private void initFromModel() { editor.setQuery(null); statusLine.update(null, List.of()); pathBrowser.setEntity(null); + browserQuery = null; return; } @@ -137,6 +141,13 @@ private void initFromModel() { properties.initFromModel(query); editor.setQuery(query); + // a tree kept from another query would insert paths of a wrong entity, so a different query starts with its + // own root, or with no tree if its String can't be parsed + if (query != browserQuery) { + browserQuery = query; + pathBrowser.setEntity(rootEntity(query)); + } + // the entity may have changed since the tree was built pathBrowser.refresh(); } @@ -146,14 +157,20 @@ private void queryChecked(List problems) { SelectQueryDescriptor query = getQuery(); statusLine.update(query, problems); if (query != null) { - // a String that can't be parsed keeps the last tree, so that it doesn't flicker while typing - if (query.isValid()) { - pathBrowser.setEntity(query.getRoot() instanceof Entity entity ? entity : null); + // a String of the same query that can't be parsed keeps the last tree, so that it doesn't flicker while + // typing + if (query != browserQuery || query.isValid()) { + browserQuery = query; + pathBrowser.setEntity(rootEntity(query)); } properties.updateDataRowsEnabled(query); } } + private static Entity rootEntity(SelectQueryDescriptor query) { + return query.getRoot() instanceof Entity entity ? entity : null; + } + private void setQueryComment(String text) { QueryDescriptor query = getQuery(); if (query == null) { diff --git a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java index ceaacc119b..10e631f0c3 100644 --- a/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java +++ b/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/project/editor/query/selectquery/QLStatusLine.java @@ -27,10 +27,9 @@ import javax.swing.JLabel; import javax.swing.UIManager; -import org.apache.cayenne.map.DbEntity; +import org.apache.cayenne.map.Entity; import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.SelectQueryDescriptor; -import org.apache.cayenne.query.FluentSelect; /** * A line under the query editor that shows what the query returns and its parameters, or the first problem of the @@ -58,7 +57,7 @@ void update(SelectQueryDescriptor query, List problems) { String more = problems.size() > 1 ? " (and " + (problems.size() - 1) + " more)" : ""; setText("✗ " + problems.getFirst() + more); setToolTipText(problems.size() > 1 ? "" + String.join("
", problems) + "" : null); - setForeground(color("Actions.Red", Color.RED)); + setForeground(color()); return; } @@ -76,22 +75,17 @@ void update(SelectQueryDescriptor query, List problems) { * Returns a description of the objects that a valid query returns. */ static String resultType(SelectQueryDescriptor query) { - FluentSelect parsed = query.getParsedQuery(); - if (query.isColumnQuery()) { - int columns = parsed.getColumns().size(); - return columns == 1 ? "Object (1 column)" : "Object[] (" + columns + " columns)"; - } - - return switch (query.getRoot()) { - case DbEntity entity -> "DataRow (" + entity.getName() + ")"; - case ObjEntity entity when query.isFetchingDataRows() -> "DataRow (" + entity.getName() + ")"; - case ObjEntity entity -> entity.getName(); - case null, default -> String.valueOf(query.getRoot()); + return switch (query.getResultKind()) { + case ENTITY -> ((ObjEntity) query.getRoot()).getName(); + case DATA_ROW -> "DataRow (" + ((Entity) query.getRoot()).getName() + ")"; + case SCALAR -> "Object (1 column)"; + case ARRAY -> "Object[] (" + query.getParsedQuery().getColumns().size() + " columns)"; + case null -> String.valueOf(query.getRoot()); }; } - private static Color color(String key, Color defaultColor) { - Color color = UIManager.getColor(key); - return color != null ? color : defaultColor; + private static Color color() { + Color color = UIManager.getColor("Actions.Red"); + return color != null ? color : Color.RED; } } diff --git a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java index fb6c750f93..6288f79a14 100644 --- a/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java +++ b/cayenne-project-tools/src/main/java/org/apache/cayenne/projecttools/validation/SelectQueryPaths.java @@ -19,19 +19,20 @@ package org.apache.cayenne.projecttools.validation; import java.util.ArrayList; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; -import java.util.Set; +import java.util.Map; import org.apache.cayenne.exp.DbPathExp; import org.apache.cayenne.exp.EnclosingObjectExp; import org.apache.cayenne.exp.Expression; import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ObjPathExp; +import org.apache.cayenne.exp.PathExp; import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.exp.path.CayennePath; import org.apache.cayenne.exp.property.Property; import org.apache.cayenne.map.DataMap; -import org.apache.cayenne.map.DbEntity; import org.apache.cayenne.map.Entity; import org.apache.cayenne.map.ObjEntity; import org.apache.cayenne.map.Relationship; @@ -42,7 +43,8 @@ /** * Checks the entity names and paths of a parsed {@link SelectQueryDescriptor} against the mapping. Used by the project - * validator, and by the Modeler to mark the problems in the query String. + * validator, and by the Modeler to mark the problems in the query String. The walk of the query that resolves the + * paths is available on its own, see {@link #walk(DataMap, FluentSelect, PathVisitor)}. * * @since 5.0 */ @@ -57,19 +59,41 @@ public enum Clause { /** * A name of an entity or a path that can't be resolved. - * - * @param name the entity name or the path, as written in the query String - * @param clause the clause of the query (or its subquery) that contains the name - * @param message a description of the problem */ public record Problem(String name, Clause clause, String message) { } + /** + * An occurrence of a path in a query or its subquery. A column alias used in place of a path is reported with the + * components of the path of its column, and is not reported if its column is not a resolvable path. + */ + public record PathRef(Clause clause, Entity entity, PathExp path, String name, + List components, Expression parent) { + } + + /** + * A callback of {@link #walk(DataMap, FluentSelect, PathVisitor)}. + */ + public interface PathVisitor { + + void visitPath(PathRef path); + + default void visitUnknownRoot(FluentSelect query) { + } + } + private final DataMap dataMap; - private final List problems = new ArrayList<>(); + private final PathVisitor visitor; + private final Entity entity; + + // the aliases of the columns can be used in place of the paths elsewhere in the query, each maps to the + // components of the path of its column, or to null if the column is not a resolvable path + private final Map> aliases = new HashMap<>(); - private SelectQueryPaths(DataMap dataMap) { + private SelectQueryPaths(DataMap dataMap, PathVisitor visitor, Entity entity) { this.dataMap = dataMap; + this.visitor = visitor; + this.entity = entity; } /** @@ -81,123 +105,144 @@ public static List check(SelectQueryDescriptor query) { return List.of(); } - SelectQueryPaths paths = new SelectQueryPaths(query.getDataMap()); - paths.checkSelect(query.getParsedQuery(), true); - return paths.problems; + List problems = new ArrayList<>(); + FluentSelect parsed = query.getParsedQuery(); + Entity root = walk(query.getDataMap(), parsed, new PathVisitor() { + + @Override + public void visitPath(PathRef path) { + if (path.components() == null) { + problems.add(new Problem(path.name(), path.clause(), + "Can't resolve path '" + path.name() + "' in " + path.entity().getName())); + } + } + + @Override + public void visitUnknownRoot(FluentSelect unknown) { + problems.add(unknown.getDbEntityName() != null + ? new Problem(unknown.getDbEntityName(), Clause.FROM, + "Unknown DbEntity '" + unknown.getDbEntityName() + "'") + : new Problem(unknown.getEntityName(), Clause.FROM, + "Unknown ObjEntity '" + unknown.getEntityName() + "'")); + } + }); + + if (root != null && parsed.getPrefetches() != null) { + for (PrefetchTreeNode node : parsed.getPrefetches().nonPhantomNodes()) { + if (node.getParent() != null) { + checkPrefetch(root, node.getPath(), problems); + } + } + } + return problems; } - private void checkSelect(FluentSelect query, boolean root) { - Entity entity = resolveRoot(query); + /** + * Walks the paths of the "select", "where", "having" and "order by" clauses of a query and of its subqueries, + * resolving each of them against the root of the (sub)query that contains it. The paths of the enclosing + * objects of a subquery are relative to the outer query, and are not walked. + * + * @return the root entity of the query, or null if it is not mapped + */ + public static Entity walk(DataMap dataMap, FluentSelect query, PathVisitor visitor) { + Entity entity = query.getDbEntityName() != null + ? dataMap.getDbEntity(query.getDbEntityName()) + : dataMap.getObjEntity(query.getEntityName()); if (entity == null) { - return; + visitor.visitUnknownRoot(query); + return null; } - // the aliases of the columns can be used in place of the paths elsewhere in the query - Set aliases = new HashSet<>(); + new SelectQueryPaths(dataMap, visitor, entity).walkClauses(query); + return entity; + } + + private void walkClauses(FluentSelect query) { if (query.getColumns() != null) { for (Property column : query.getColumns()) { - checkExpression(entity, column.getExpression(), Clause.SELECT, aliases); + PathRef path = walkExpression(column.getExpression(), null, Clause.SELECT); if (column.getAlias() != null) { - aliases.add(column.getAlias()); + aliases.put(column.getAlias(), path != null ? path.components() : null); } } } - checkExpression(entity, query.getWhere(), Clause.WHERE, aliases); - checkExpression(entity, query.getHaving(), Clause.HAVING, aliases); + walkExpression(query.getWhere(), null, Clause.WHERE); + walkExpression(query.getHaving(), null, Clause.HAVING); if (query.getOrderings() != null) { for (Ordering ordering : query.getOrderings()) { - checkExpression(entity, ordering.getSortSpec(), Clause.ORDER_BY, aliases); - } - } - - if (root && query.getPrefetches() != null) { - for (PrefetchTreeNode node : query.getPrefetches().nonPhantomNodes()) { - if (node.getParent() != null) { - checkPrefetch(entity, node.getPath().value()); - } + walkExpression(ordering.getSortSpec(), null, Clause.ORDER_BY); } } } - private Entity resolveRoot(FluentSelect query) { - if (query.getDbEntityName() != null) { - DbEntity entity = dataMap.getDbEntity(query.getDbEntityName()); - if (entity == null) { - problems.add(new Problem(query.getDbEntityName(), Clause.FROM, - "Unknown DbEntity '" + query.getDbEntityName() + "'")); - } - return entity; - } - - ObjEntity entity = dataMap.getObjEntity(query.getEntityName()); - if (entity == null) { - problems.add(new Problem(query.getEntityName(), Clause.FROM, - "Unknown ObjEntity '" + query.getEntityName() + "'")); - } - return entity; - } - - private void checkExpression(Entity entity, Expression expression, Clause clause, Set aliases) { + // returns the visited path if the expression is a path itself + private PathRef walkExpression(Expression expression, Expression parent, Clause clause) { if (expression == null) { - return; + return null; } - switch (expression) { - // the path of an enclosing object is relative to the outer query, that is checked on its own - case EnclosingObjectExp ignored -> { - } + PathRef path = switch (expression) { + // the path of an enclosing object is relative to the outer query, that is walked on its own + case EnclosingObjectExp ignored -> null; case SubqueryExp subquery -> { if (subquery.getQuery() != null) { - checkSelect(subquery.getQuery(), false); + walk(dataMap, subquery.getQuery(), visitor); } + yield null; } // there are no ObjPathExp in a query rooted in a DbEntity, all its paths are DB paths - case ObjPathExp path when !aliases.contains(path.getPath().value()) -> - checkPath(entity, path.getExpandedPath().value(), path.getPath().value(), clause); - case DbPathExp path -> { - // a DB path needs no "db:" prefix in a query rooted in a DbEntity - if (entity instanceof ObjEntity objEntity) { - if (objEntity.getDbEntity() != null) { - checkPath(objEntity.getDbEntity(), path.getExpandedPath().value(), - "db:" + path.getPath().value(), clause); - } - } else if (!aliases.contains(path.getPath().value())) { - checkPath(entity, path.getExpandedPath().value(), path.getPath().value(), clause); - } - } + case ObjPathExp objPath -> pathRef(entity, objPath, objPath.getPath().value(), parent, clause); + // a DB path of a query rooted in an ObjEntity is resolved against its DbEntity, with a "db:" prefix + case DbPathExp dbPath when entity instanceof ObjEntity objEntity -> objEntity.getDbEntity() != null + ? pathRef(objEntity.getDbEntity(), dbPath, "db:" + dbPath.getPath().value(), parent, clause) + : null; + // a DB path needs no "db:" prefix in a query rooted in a DbEntity + case DbPathExp dbPath -> pathRef(entity, dbPath, dbPath.getPath().value(), parent, clause); default -> { for (int i = 0; i < expression.getOperandCount(); i++) { if (expression.getOperand(i) instanceof Expression operand) { - checkExpression(entity, operand, clause, aliases); + walkExpression(operand, expression, clause); } } + yield null; } + }; + + if (path != null) { + visitor.visitPath(path); } + return path; } - private void checkPath(Entity entity, String path, String name, Clause clause) { - try { - entity.resolvePath(path); - } catch (ExpressionException e) { - problems.add(new Problem(name, clause, "Can't resolve path '" + name + "' in " + entity.getName())); + // an alias is never a "db:" path, as it is a simple name + private PathRef pathRef(Entity target, PathExp path, String name, Expression parent, Clause clause) { + if (!aliases.containsKey(name)) { + return new PathRef(clause, target, path, name, resolve(target, path.getExpandedPath()), parent); } + + // an alias of a column that is not a resolvable path is not a path either + List components = aliases.get(name); + return components != null ? new PathRef(clause, target, path, name, components, parent) : null; } - private void checkPrefetch(Entity entity, String path) { - List components; + private static List resolve(Entity entity, CayennePath path) { try { - components = entity.resolvePath(path); + return entity.resolvePath(path); } catch (ExpressionException e) { - problems.add(new Problem(path, Clause.PREFETCH, - "Can't resolve prefetch path '" + path + "' in " + entity.getName())); - return; + return null; } + } - if (!(components.getLast() instanceof Relationship)) { - problems.add(new Problem(path, Clause.PREFETCH, - "Prefetch path '" + path + "' doesn't end with a relationship")); + private static void checkPrefetch(Entity entity, CayennePath path, List problems) { + List components = resolve(entity, path); + if (components == null) { + problems.add(new Problem(path.value(), Clause.PREFETCH, + "Can't resolve prefetch path '" + path.value() + "' in " + entity.getName())); + } else if (!(components.getLast() instanceof Relationship)) { + problems.add(new Problem(path.value(), Clause.PREFETCH, + "Prefetch path '" + path.value() + "' doesn't end with a relationship")); } } } diff --git a/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java b/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java index a8d3835739..ea110eed5a 100644 --- a/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java +++ b/cayenne/src/main/java/org/apache/cayenne/map/DataMap.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.Objects; import java.util.TreeMap; +import java.util.regex.Pattern; import static java.util.Collections.emptyList; @@ -518,9 +519,15 @@ public void renameObjEntity(ObjEntity entity, String newName) { // the select queries reference entities by name in their query Strings, so a rename has to follow into them private void renameEntityInQueries(String oldName, String newName, boolean db) { - for (QueryDescriptor query : queryDescriptorMap.values()) { - if (query instanceof SelectQueryDescriptor select) { - select.renameEntity(oldName, newName, db); + Pattern reference = SelectQueryDescriptor.renamePattern(oldName, db); + Collection mapList = getNamespace() instanceof EntityResolver resolver + ? resolver.getDataMaps() + : Collections.singletonList(this); + for (DataMap map : mapList) { + for (QueryDescriptor query : map.queryDescriptorMap.values()) { + if (query instanceof SelectQueryDescriptor select) { + select.renameEntity(reference, newName); + } } } } diff --git a/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java b/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java index 5a17f6490f..54e95c981e 100644 --- a/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java +++ b/cayenne/src/main/java/org/apache/cayenne/map/SelectQueryDescriptor.java @@ -140,6 +140,24 @@ public boolean isDbQuery() { return query != null && query.getDbEntityName() != null; } + /** + * Returns the kind of the objects that the query fetches. + * + * @since 5.0 + */ + public ResultKind getResultKind() { + if (parsedOrNull() instanceof ColumnSelect columnSelect) { + return columnSelect.getColumns().size() == 1 ? ResultKind.SCALAR : ResultKind.ARRAY; + } + + return switch (getRoot()) { + case ObjEntity ignored when !fetchingDataRows -> ResultKind.ENTITY; + case ObjEntity ignored -> ResultKind.DATA_ROW; + case DbEntity ignored -> ResultKind.DATA_ROW; + case null, default -> null; + }; + } + /** * Returns the names of the "$name" parameters of the query, in the order of their first occurrence. * @@ -169,22 +187,24 @@ private static void collectParameterNames(FluentSelect query, Set } /** - * Renames an entity in the "from" clauses of the query String, those of the subqueries included. The rest of the - * String, its formatting and string literals included, is kept as is. The String is not parsed, so it may be one - * that can't be. + * Returns a Pattern matching the references to an entity in the "from" clauses of a query String, for + * {@link #renameEntity(Pattern, String)}. * * @param db whether the entity is a DbEntity, i.e. is referenced as "from db:NAME" */ - void renameEntity(String oldName, String newName, boolean db) { - if (ql == null || oldName.equals(newName)) { + static Pattern renamePattern(String name, boolean db) { + return Pattern.compile(QUOTED_STRING + "|(\\bfrom\\s+" + (db ? "db:\\s*" : "") + ")" + + Pattern.quote(name) + "(?![\\w$.])", Pattern.DOTALL); + } + + /** + * Renames an entity in the "from" clauses of the query String + */ + void renameEntity(Pattern reference, String newName) { + if (ql == null) { return; } - // A scan of the String is exact for any String that parses: the grammar has no comments, and "from" followed - // by a simple name is a "from" clause anywhere but inside a string literal, which is why the literals are - // matched (and kept) too. The keyword is case-sensitive, and the name must be a whole token. - Pattern reference = Pattern.compile(QUOTED_STRING + "|(\\bfrom\\s+" + (db ? "db:\\s*" : "") + ")" - + Pattern.quote(oldName) + "(?![\\w$.])", Pattern.DOTALL); String renamed = reference.matcher(ql) .replaceAll(m -> Matcher.quoteReplacement(m.group(1) == null ? m.group() : m.group(1) + newName)); if (!renamed.equals(ql)) { @@ -341,6 +361,34 @@ public void encodeAsXML(XMLEncoder encoder, ProjectNodeVisitor delegate) { encoder.end(); } + /** + * The kind of the objects that a select query fetches, see {@link #getResultKind()}. + * + * @since 5.0 + */ + public enum ResultKind { + + /** + * The persistent objects of the root ObjEntity. + */ + ENTITY, + + /** + * DataRows, for a query rooted in a DbEntity or fetching DataRows. + */ + DATA_ROW, + + /** + * The values of the single column of a column query. + */ + SCALAR, + + /** + * The arrays of the values of the multiple columns of a column query. + */ + ARRAY + } + // collects the names of the unbound "$name" parameters of the expressions, including those of the subqueries private record ParameterCollector(Set names) implements TraversalHandler { diff --git a/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java b/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java index 35c758024b..54792a5b7f 100644 --- a/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/map/DataMapTest.java @@ -90,6 +90,61 @@ public void renameDbEntityUpdatesSelectQueries() { assertEquals("from ARTIST where NAME = 'a'", obj.getQl()); } + @Test + public void renameObjEntityUpdatesSelectQueriesInOtherMaps() { + DataMap mapA = new DataMap("a"); + DataMap mapB = new DataMap("b"); + EntityResolver resolver = new EntityResolver(); + resolver.addDataMap(mapA); + resolver.addDataMap(mapB); + + // the cache built here is stale once the entity is added + assertNull(resolver.getObjEntity("Artist")); + ObjEntity artist = new ObjEntity("Artist"); + mapA.addObjEntity(artist); + mapB.addObjEntity(new ObjEntity("Painting")); + + SelectQueryDescriptor inA = selectQuery("inA", "from Artist"); + SelectQueryDescriptor inB = selectQuery("inB", + "from Artist where exists (from Painting where artist = enclosing(self))"); + SelectQueryDescriptor subqueryInB = selectQuery("subqueryInB", + "from Painting where exists (from Artist where artist = enclosing(self))"); + mapA.addQueryDescriptor(inA); + mapB.addQueryDescriptor(inB); + mapB.addQueryDescriptor(subqueryInB); + + mapA.renameObjEntity(artist, "Painter"); + + assertEquals("from Painter", inA.getQl()); + assertEquals("from Painter where exists (from Painting where artist = enclosing(self))", inB.getQl()); + assertSame(artist, inB.getRoot()); + assertEquals("from Painting where exists (from Painter where artist = enclosing(self))", subqueryInB.getQl()); + } + + @Test + public void renameDbEntityUpdatesSelectQueriesInOtherMaps() { + DataMap mapA = new DataMap("a"); + DataMap mapB = new DataMap("b"); + EntityResolver resolver = new EntityResolver(); + resolver.addDataMap(mapA); + resolver.addDataMap(mapB); + + DbEntity table = new DbEntity("ARTIST"); + mapA.addDbEntity(table); + mapB.addObjEntity(new ObjEntity("ARTIST")); + + SelectQueryDescriptor db = selectQuery("db", "from db:ARTIST where NAME = 'a'"); + SelectQueryDescriptor obj = selectQuery("obj", "from ARTIST where NAME = 'a'"); + mapB.addQueryDescriptor(db); + mapB.addQueryDescriptor(obj); + + mapA.renameDbEntity(table, "PAINTER"); + + assertEquals("from db:PAINTER where NAME = 'a'", db.getQl()); + assertSame(table, db.getRoot()); + assertEquals("from ARTIST where NAME = 'a'", obj.getQl()); + } + @Test public void removeQueryDescriptorDetachesIt() { DataMap map = new DataMap("map"); diff --git a/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java b/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java index 14249a0c46..55b4f46abc 100644 --- a/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java +++ b/cayenne/src/test/java/org/apache/cayenne/map/SelectQueryDescriptorTest.java @@ -29,6 +29,7 @@ import org.apache.cayenne.exp.ExpressionException; import org.apache.cayenne.exp.ExpressionFactory; import org.apache.cayenne.exp.SubqueryExp; +import org.apache.cayenne.map.SelectQueryDescriptor.ResultKind; import org.apache.cayenne.project.EmptyProjectNodeVisitor; import org.apache.cayenne.query.ColumnSelect; import org.apache.cayenne.query.FluentSelect; @@ -40,6 +41,7 @@ import org.apache.cayenne.util.XMLEncoder; import org.junit.jupiter.api.Test; +import static org.apache.cayenne.map.SelectQueryDescriptor.renamePattern; import static org.junit.jupiter.api.Assertions.*; public class SelectQueryDescriptorTest { @@ -187,6 +189,40 @@ public void getRoot() { assertEquals("B", descriptor.getRoot()); } + @Test + public void getResultKind() { + DataMap map = new DataMap(); + map.addObjEntity(new ObjEntity("A")); + map.addDbEntity(new DbEntity("T")); + + SelectQueryDescriptor descriptor = descriptor("from A"); + descriptor.setDataMap(map); + assertEquals(ResultKind.ENTITY, descriptor.getResultKind()); + + descriptor.setFetchingDataRows(true); + assertEquals(ResultKind.DATA_ROW, descriptor.getResultKind()); + descriptor.setFetchingDataRows(false); + + descriptor.setQl("from db:T"); + assertEquals(ResultKind.DATA_ROW, descriptor.getResultKind()); + + descriptor.setQl("select a from A"); + assertEquals(ResultKind.SCALAR, descriptor.getResultKind()); + + descriptor.setQl("select a, b from A"); + assertEquals(ResultKind.ARRAY, descriptor.getResultKind()); + + // the columns are known without the mapping + descriptor.setQl("select a, b from B"); + assertEquals(ResultKind.ARRAY, descriptor.getResultKind()); + + // an unmapped root, or a String that can't be parsed + descriptor.setQl("from B"); + assertNull(descriptor.getResultKind()); + descriptor.setQl("from A where"); + assertNull(descriptor.getResultKind()); + } + @Test public void setRoot() { assertThrows(UnsupportedOperationException.class, () -> new SelectQueryDescriptor().setRoot("A")); @@ -277,7 +313,7 @@ public void encodeAsXMLEmpty() { @Test public void renameEntity() { SelectQueryDescriptor descriptor = descriptor("select name from Artist\n order by name"); - descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("select name from Painter\n order by name", descriptor.getQl()); assertEquals("Painter", descriptor.getParsedQuery().getEntityName()); } @@ -286,7 +322,7 @@ public void renameEntity() { public void renameEntityInSubquery() { SelectQueryDescriptor descriptor = descriptor( "from Painting where exists (from Artist where artist = enclosing(self))"); - descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("from Painting where exists (from Painter where artist = enclosing(self))", descriptor.getQl()); } @@ -295,8 +331,8 @@ public void renameEntityWholeTokenOnly() { String ql = "from Artist2 where fromArtist = 'Artist' and artist.name = 'x' and from = 1" + " and exists (from Artist$1)"; SelectQueryDescriptor descriptor = descriptor(ql); - descriptor.renameEntity("Artist", "Painter", false); - descriptor.renameEntity("artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); + descriptor.renameEntity(renamePattern("artist", false), "Painter"); assertSame(ql, descriptor.getQl()); } @@ -304,54 +340,54 @@ public void renameEntityWholeTokenOnly() { public void renameEntitySkipsStringLiterals() { String literals = " where name = 'from Artist' or bio = \"from Artist \\\" from Artist\" or name = 'it\\'s'"; SelectQueryDescriptor descriptor = descriptor("from Artist" + literals); - descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("from Painter" + literals, descriptor.getQl()); // a literal can span lines descriptor = descriptor("from Artist where name = 'from\nArtist' and exists (from Artist)"); - descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("from Painter where name = 'from\nArtist' and exists (from Painter)", descriptor.getQl()); // an unterminated literal of a String that can't be parsed doesn't stop the rename descriptor = descriptor("from Artist where name = 'from Artist"); - descriptor.renameEntity("Artist", "Painter", false); + descriptor.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("from Painter where name = 'from Painter", descriptor.getQl()); } @Test public void renameDbEntity() { SelectQueryDescriptor descriptor = descriptor("from db:ARTIST where NAME = 'a'"); - descriptor.renameEntity("ARTIST", "PAINTER", true); + descriptor.renameEntity(renamePattern("ARTIST", true), "PAINTER"); assertEquals("from db:PAINTER where NAME = 'a'", descriptor.getQl()); // the grammar allows whitespace after the prefix SelectQueryDescriptor spaced = descriptor("from db: ARTIST"); - spaced.renameEntity("ARTIST", "PAINTER", true); + spaced.renameEntity(renamePattern("ARTIST", true), "PAINTER"); assertEquals("from db: PAINTER", spaced.getQl()); // an ObjEntity of the same name is a different reference - descriptor.renameEntity("PAINTER", "ARTIST", false); + descriptor.renameEntity(renamePattern("PAINTER", false), "ARTIST"); assertEquals("from db:PAINTER where NAME = 'a'", descriptor.getQl()); SelectQueryDescriptor objDescriptor = descriptor("from ARTIST where NAME = 'a'"); - objDescriptor.renameEntity("ARTIST", "PAINTER", true); + objDescriptor.renameEntity(renamePattern("ARTIST", true), "PAINTER"); assertEquals("from ARTIST where NAME = 'a'", objDescriptor.getQl()); } @Test public void renameEntityNullAndSameName() { SelectQueryDescriptor empty = new SelectQueryDescriptor(); - empty.renameEntity("Artist", "Painter", false); + empty.renameEntity(renamePattern("Artist", false), "Painter"); assertNull(empty.getQl()); // a String that can't be parsed is renamed too, as it is kept to be fixed SelectQueryDescriptor invalid = descriptor("from Artist where"); - invalid.renameEntity("Artist", "Painter", false); + invalid.renameEntity(renamePattern("Artist", false), "Painter"); assertEquals("from Painter where", invalid.getQl()); String ql = "from Artist"; SelectQueryDescriptor same = descriptor(ql); - same.renameEntity("Artist", "Artist", false); + same.renameEntity(renamePattern("Artist", false), "Artist"); assertSame(ql, same.getQl()); }