projected = new HashSet<>();
+ projected.add(this.getMasterTableIdFieldName());
+ for (FieldSearchFilter filter : filters) {
+ if (null == filter.getKey() || null == filter.getOrder() || filter.isNullOption()) {
+ continue;
+ }
+ String fieldName = this.resolveTableFieldName(filter.getKey());
+ // two filters can order on the same column; a count wraps this block in a derived table, and
+ // a derived table may not repeat a column name
+ if (projected.add(fieldName)) {
+ query.append(", ").append(this.getMasterTableName()).append(".").append(fieldName);
+ }
+ }
+ }
+
protected boolean verifyWhereClauseAppend(StringBuffer query, boolean hasAppendWhereClause) {
if (hasAppendWhereClause) {
query.append("AND ");
@@ -386,7 +512,40 @@ protected boolean verifyWhereClauseAppend(StringBuffer query, boolean hasAppendW
return hasAppendWhereClause;
}
- protected abstract String getTableFieldName(String metadataFieldKey);
+ /**
+ * The column a caller-provided metadata key names, looked up in the fields this searcher accepts.
+ * Every query block that concatenates a column name goes through here.
+ *
+ * Filter values are bound as parameters, but a filter key becomes a column name
+ * by concatenation - in the WHERE block, in the ORDER BY, in the projection and in the GROUP BY -
+ * so an unchecked key is a SQL injection vector. Searchers used to map the key themselves, either
+ * straight through - relying on the REST layer validating it against a DTO's fields, a guarantee
+ * made far from here, invisible to static analysis, and absent for every non-REST caller - or
+ * through a chain of comparisons that each had to remember to reject the unknown key.
+ *
+ * The lookup happens here instead, once, so no subclass can be written that skips it, and what
+ * comes back is the column as {@link #getSearchableFields()} declares it, never as the caller
+ * spelled it.
+ *
+ * @param metadataFieldKey The key supplied by the caller.
+ * @return The column it names, in the spelling this searcher declares.
+ */
+ protected final String resolveTableFieldName(String metadataFieldKey) {
+ String column = this.getSearchableFields().columnFor(metadataFieldKey);
+ if (null == column) {
+ logger.error("Unrecognized search key '{}' for {}", metadataFieldKey, this.getClass().getName());
+ throw new EntRuntimeException("Unrecognized search key for " + this.getClass().getName());
+ }
+ return column;
+ }
+
+ /**
+ * The search keys this searcher accepts and the column each one names, as literals - never caller
+ * input. A key outside them is refused.
+ *
+ * @return The accepted fields.
+ */
+ protected abstract SearchableFields getSearchableFields();
/**
* Return the name of the entities master table.
diff --git a/engine/src/main/java/com/agiletec/aps/system/common/SearchableFields.java b/engine/src/main/java/com/agiletec/aps/system/common/SearchableFields.java
new file mode 100644
index 0000000000..01d840edd1
--- /dev/null
+++ b/engine/src/main/java/com/agiletec/aps/system/common/SearchableFields.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package com.agiletec.aps.system.common;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * The search keys a searcher DAO accepts, and the column each one names.
+ *
+ * A filter key becomes a column name by concatenation, so the set of keys a searcher accepts is
+ * also the bound on what can reach the SQL. Declaring it as data - rather than as a chain of
+ * comparisons inside each DAO - is what lets {@link AbstractSearcherDAO} apply the same check to
+ * every searcher, and lets a searcher whose keys differ from its columns say so instead of relying
+ * on the two happening to coincide.
+ *
+ * Keys are matched without regard to case, because the keys callers send are DTO field names
+ * (pluginCode) while columns are spelled as the schema declares them
+ * (plugincode), and every database the engine supports folds unquoted identifiers. What
+ * a lookup returns is always the column as declared here, never as the caller spelled it.
+ *
+ * @author E.Santoboni
+ */
+public final class SearchableFields {
+
+ private final Map columnsByKey;
+
+ private SearchableFields(Map columnsByKey) {
+ this.columnsByKey = columnsByKey;
+ }
+
+ /**
+ * Fields whose key is the column name.
+ *
+ * @param columns The columns, as literals - never caller input.
+ * @return The fields those columns make up.
+ */
+ public static SearchableFields columns(String... columns) {
+ Map columnsByKey = new HashMap<>();
+ for (String column : columns) {
+ columnsByKey.put(normalize(column), column);
+ }
+ return new SearchableFields(columnsByKey);
+ }
+
+ /**
+ * These fields, plus a key naming a column spelled differently - a logical key such as
+ * entityId, or one the REST layer exposes under another name.
+ *
+ * @param searchKey The key callers use.
+ * @param column The column it names, as a literal - never caller input.
+ * @return The fields, with that key added.
+ */
+ public SearchableFields alias(String searchKey, String column) {
+ Map widened = new HashMap<>(this.columnsByKey);
+ widened.put(normalize(searchKey), column);
+ return new SearchableFields(widened);
+ }
+
+ /**
+ * The column a key names.
+ *
+ * @param searchKey The key supplied by the caller.
+ * @return The column, as declared here, or null when the key is not one of these fields.
+ */
+ String columnFor(String searchKey) {
+ return (null == searchKey) ? null : this.columnsByKey.get(normalize(searchKey));
+ }
+
+ private static String normalize(String searchKey) {
+ return searchKey.toLowerCase(Locale.ROOT);
+ }
+
+}
diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
index cbb25efb0c..bf33d2a92f 100644
--- a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
+++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
@@ -20,9 +20,12 @@
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Date;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import com.agiletec.aps.system.common.AbstractSearcherDAO;
+import com.agiletec.aps.system.common.FieldSearchFilter;
import com.agiletec.aps.system.common.entity.model.ApsEntityRecord;
import com.agiletec.aps.system.common.entity.model.EntitySearchFilter;
import org.entando.entando.ent.util.EntLogging.EntLogger;
@@ -36,6 +39,8 @@
public abstract class AbstractEntitySearcherDAO extends AbstractSearcherDAO implements IEntitySearcherDAO {
private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(AbstractEntitySearcherDAO.class);
+ private static final String TEXTVALUE = "textvalue";
+
@Override
public List searchRecords(EntitySearchFilter[] filters) {
@@ -138,7 +143,7 @@ private PreparedStatement buildStatement(EntitySearchFilter[] filters, boolean i
String query = this.createQueryString(filters, isCount, selectAll);
PreparedStatement stat = null;
try {
- stat = conn.prepareStatement(query);
+ stat = this.prepareStatement(conn, query);
int index = 0;
index = this.addAttributeFilterStatementBlock(filters, index, stat);
index = this.addMetadataFieldFilterStatementBlock(filters, index, stat);
@@ -216,11 +221,12 @@ protected String createQueryString(EntitySearchFilter[] filters, boolean isCount
StringBuffer query = this.createBaseQueryBlock(filters, isCount, selectAll);
boolean hasAppendWhereClause = this.appendFullAttributeFilterQueryBlocks(filters, query, false);
this.appendMetadataFieldFilterQueryBlocks(filters, query, hasAppendWhereClause);
+ boolean grouped = this.appendGroupByQueryBlock(filters, query, selectAll);
if (!isCount) {
- boolean ordered = this.appendOrderQueryBlocks(filters, query, false);
+ this.appendOrderQueryBlocks(filters, query, false, grouped);
this.appendLimitQueryBlock(filters, query);
}
- return query.toString();
+ return this.toQueryString(query, isCount);
}
/**
@@ -236,7 +242,10 @@ protected String createQueryString(EntitySearchFilter[] filters, boolean isCount
protected StringBuffer createBaseQueryBlock(EntitySearchFilter[] filters, boolean isCount, boolean selectAll) {
StringBuffer query = null;
if (isCount) {
- query = this.createMasterCountQueryBlock();
+ // count the rows of the very select block the list query pages over, so that the two can
+ // never disagree: an attribute filter joins the search table and can match several rows
+ // per entity, and LIMIT/OFFSET is applied to whatever that block returns
+ query = this.createMasterSelectQueryBlock(filters, false);
} else {
query = this.createMasterSelectQueryBlock(filters, selectAll);
}
@@ -246,33 +255,179 @@ protected StringBuffer createBaseQueryBlock(EntitySearchFilter[] filters, boolea
protected StringBuffer createMasterSelectQueryBlock(EntitySearchFilter[] filters, boolean selectAll) {
String masterTableName = this.getEntityMasterTableName();
- StringBuffer query = new StringBuffer("SELECT ").append(masterTableName).append(".");
+ boolean grouped = this.isGroupedByMasterId(filters, selectAll);
+ StringBuffer query = new StringBuffer("SELECT ");
+ if (!selectAll && !grouped) {
+ // GROUP BY on the master id already returns one row per entity
+ query.append("DISTINCT ");
+ }
+ query.append(masterTableName).append(".");
if (selectAll) {
query.append("* ");
} else {
query.append(this.getEntityMasterTableIdFieldName());
}
if (filters != null) {
- String searchTableName = this.getEntitySearchTableName();
- for (int i = 0; i < filters.length; i++) {
- EntitySearchFilter filter = filters[i];
- if (!filter.isAttributeFilter() && filter.isLikeOption()) {
- String tableFieldName = this.getTableFieldName(filter.getKey());
- //check for id column already present
- if (!tableFieldName.equals(this.getMasterTableIdFieldName())) {
- query.append(", ").append(masterTableName).append(".").append(tableFieldName);
- }
- } else if (filter.isAttributeFilter() && filter.isLikeOption()) {
- String columnName = this.getAttributeFieldColunm(filter);
- query.append(", ").append(searchTableName).append(i).append(".").append(columnName);
- query.append(" AS ").append(columnName).append(i).append(" ");
- }
+ if (selectAll) {
+ this.appendLikeFieldsSelectBlock(filters, query);
+ } else {
+ this.appendOrderFieldsSelectBlock(filters, query, grouped);
}
}
query.append(" FROM ").append(masterTableName).append(" ");
return query;
}
+ /**
+ * Whether the body collapses the entity in SQL rather than with DISTINCT.
+ *
+ * DISTINCT cannot collapse an entity that is ordered by an attribute: the ORDER BY
+ * names a column of the joined search table, that column has to be projected, and an entity
+ * holding one value per language then produces rows that are genuinely distinct. Grouping on the
+ * master id collapses it, and the attribute is reached through an aggregate instead.
+ *
+ * Deliberately scoped to that case. Ordering on metadata alone cannot multiply a row, so those
+ * searches - the large majority - keep the plan, the totals and the row order they have.
+ *
+ * @param filters The filters of the query.
+ * @param selectAll True when the query loads whole records; that path has no count paired with it
+ * and projects the master table's CLOB columns, so it is never grouped.
+ * @return True when the body must group by the master id.
+ */
+ protected boolean isGroupedByMasterId(EntitySearchFilter[] filters, boolean selectAll) {
+ if (selectAll || null == filters) {
+ return false;
+ }
+ for (EntitySearchFilter filter : filters) {
+ if (this.isOrderFilter(filter) && filter.isAttributeFilter()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The filters the ORDER BY block will emit a term for. Every method that has to stay aligned with
+ * that block - the projection, the GROUP BY - asks this rather than repeating the condition.
+ *
+ * @param filter The filter to test.
+ * @return True when the filter carries an order the query builder honours.
+ */
+ private boolean isOrderFilter(EntitySearchFilter filter) {
+ return (null != filter.getKey() || null != filter.getRoleName())
+ && null != filter.getOrder() && !filter.isNullOption();
+ }
+
+ /**
+ * The master-table columns the ORDER BY block references, de-duplicated and in the order the
+ * filters declare them. They are projected, and when the body groups they are also grouped on:
+ * Derby and Oracle both reject an un-aggregated column that is not in the GROUP BY, even one
+ * functionally dependent on the grouping key that MySQL and PostgreSQL accept.
+ *
+ * @param filters The filters of the query.
+ * @return The column names, without the table prefix.
+ */
+ private List metadataOrderColumns(EntitySearchFilter[] filters) {
+ List columns = new ArrayList<>();
+ if (null == filters) {
+ return columns;
+ }
+ for (EntitySearchFilter filter : filters) {
+ if (!this.isOrderFilter(filter) || filter.isAttributeFilter()) {
+ continue;
+ }
+ String fieldName = this.resolveTableFieldName(filter.getKey());
+ // two filters can order on the same column; a count wraps this block in a derived table,
+ // and a derived table may not repeat a column name
+ if (!columns.contains(fieldName) && !fieldName.equals(this.getEntityMasterTableIdFieldName())) {
+ columns.add(fieldName);
+ }
+ }
+ return columns;
+ }
+
+ /**
+ * Group the body on the master id so that an entity holding several values for the ordered
+ * attribute collapses to one row. Part of the body, not of the order block: the count wraps the
+ * same block, so it counts groups and its total becomes exact.
+ *
+ * @param filters The filters of the query.
+ * @param query The query under construction.
+ * @param selectAll True when the query loads whole records.
+ * @return True when the clause was appended, to be handed to
+ * {@link #appendOrderQueryBlocks(EntitySearchFilter[], StringBuffer, boolean, boolean)} - the two
+ * cannot disagree because one produces what the other consumes.
+ */
+ protected boolean appendGroupByQueryBlock(EntitySearchFilter[] filters, StringBuffer query, boolean selectAll) {
+ if (!this.isGroupedByMasterId(filters, selectAll)) {
+ return false;
+ }
+ String masterTableName = this.getEntityMasterTableName();
+ query.append("GROUP BY ").append(masterTableName).append(".")
+ .append(this.getEntityMasterTableIdFieldName());
+ for (String column : this.metadataOrderColumns(filters)) {
+ query.append(", ").append(masterTableName).append(".").append(column);
+ }
+ query.append(" ");
+ return true;
+ }
+
+ private void appendLikeFieldsSelectBlock(EntitySearchFilter[] filters, StringBuffer query) {
+ String masterTableName = this.getEntityMasterTableName();
+ String searchTableName = this.getEntitySearchTableName();
+ for (int i = 0; i < filters.length; i++) {
+ EntitySearchFilter filter = filters[i];
+ if (!filter.isAttributeFilter() && filter.isLikeOption()) {
+ String tableFieldName = this.resolveTableFieldName(filter.getKey());
+ //check for id column already present
+ if (!tableFieldName.equals(this.getMasterTableIdFieldName())) {
+ query.append(", ").append(masterTableName).append(".").append(tableFieldName);
+ }
+ } else if (filter.isAttributeFilter() && filter.isLikeOption()) {
+ String columnName = this.getAttributeFieldColunm(filter);
+ query.append(", ").append(searchTableName).append(i).append(".").append(columnName);
+ query.append(" AS ").append(columnName).append(i).append(" ");
+ }
+ }
+ }
+
+ /**
+ * Project the columns the ORDER BY block will reference. Under DISTINCT they have to appear in the
+ * select list, and they are the only extra columns allowed to: any other column of the joined
+ * search table would make the entity distinct again, row by row.
+ *
+ * When the body groups, the attribute column is deliberately not projected. Projecting
+ * it is what stops DISTINCT collapsing the entity, and the ORDER BY reaches it through an
+ * aggregate instead, which needs no projection.
+ *
+ * @param filters The filters of the query.
+ * @param query The query under construction.
+ * @param grouped True when the body groups by the master id.
+ */
+ private void appendOrderFieldsSelectBlock(EntitySearchFilter[] filters, StringBuffer query, boolean grouped) {
+ for (String column : this.metadataOrderColumns(filters)) {
+ query.append(", ").append(this.getEntityMasterTableName()).append(".").append(column);
+ }
+ if (grouped) {
+ return;
+ }
+ for (int i = 0; i < filters.length; i++) {
+ EntitySearchFilter filter = filters[i];
+ if (!this.isOrderFilter(filter) || !filter.isAttributeFilter()) {
+ continue;
+ }
+ String searchTableNameAlias = this.getEntitySearchTableName() + i;
+ String columnName = this.getAttributeFieldColunm(this.getOrderReferenceValue(filter));
+ if (null == columnName) {
+ query.append(", ").append(searchTableNameAlias).append(".textvalue");
+ query.append(", ").append(searchTableNameAlias).append(".datevalue");
+ query.append(", ").append(searchTableNameAlias).append(".numvalue");
+ } else {
+ query.append(", ").append(searchTableNameAlias).append(".").append(columnName);
+ }
+ }
+ }
+
protected void appendJoinSearchTableQueryBlock(EntitySearchFilter[] filters, StringBuffer query) {
if (filters == null) {
return;
@@ -413,13 +568,33 @@ protected boolean appendMetadataFieldFilterQueryBlocks(EntitySearchFilter[] filt
return hasAppendWhereClause;
}
+ /**
+ * Order a body that does not group. Kept for callers that build an un-grouped query; a caller that
+ * appended a GROUP BY must use the four-argument form, or the attribute term would name a column
+ * that is neither grouped nor aggregated.
+ */
protected boolean appendOrderQueryBlocks(EntitySearchFilter[] filters, StringBuffer query, boolean ordered) {
+ return this.appendOrderQueryBlocks(filters, query, ordered, false);
+ }
+
+ /**
+ * @param filters The filters of the query.
+ * @param query The query under construction.
+ * @param ordered Whether an ORDER BY block was already opened.
+ * @param grouped True when the body groups by the master id, as reported by
+ * {@link #appendGroupByQueryBlock}.
+ * @return Whether an ORDER BY block is open.
+ */
+ protected boolean appendOrderQueryBlocks(EntitySearchFilter[] filters, StringBuffer query, boolean ordered,
+ boolean grouped) {
if (filters == null) {
return ordered;
}
+ Set orderedFields = new HashSet<>();
+ Object lastOrder = null;
for (int i = 0; i < filters.length; i++) {
EntitySearchFilter filter = filters[i];
- if ((null != filter.getKey() || null != filter.getRoleName()) && null != filter.getOrder() && !filter.isNullOption()) {
+ if (this.isOrderFilter(filter)) {
if (!ordered) {
query.append("ORDER BY ");
ordered = true;
@@ -428,13 +603,16 @@ protected boolean appendOrderQueryBlocks(EntitySearchFilter[] filters, StringBuf
}
if (filter.isAttributeFilter()) {
String tableName = this.getEntitySearchTableName() + i;
- this.addAttributeOrderQueryBlock(tableName, query, filter, filter.getOrder().toString());
+ this.addAttributeOrderQueryBlock(tableName, query, filter, filter.getOrder().toString(), grouped);
} else {
- String fieldName = this.getTableFieldName(filter.getKey());
+ String fieldName = this.resolveTableFieldName(filter.getKey());
query.append(this.getEntityMasterTableName()).append(".").append(fieldName).append(" ").append(filter.getOrder());
+ orderedFields.add(fieldName);
}
+ lastOrder = filter.getOrder();
}
}
+ this.appendOrderTieBreaker(query, ordered, orderedFields, this.getEntityMasterTableIdFieldName(), lastOrder);
return ordered;
}
@@ -470,10 +648,49 @@ protected boolean verifyWhereClauseAppend(StringBuffer query, boolean hasAppendW
return hasAppendWhereClause;
}
- private void addAttributeOrderQueryBlock(String searchTableNameAlias, StringBuffer query, EntitySearchFilter filter, String order) {
+ private void addAttributeOrderQueryBlock(String searchTableNameAlias, StringBuffer query,
+ EntitySearchFilter filter, String order, boolean grouped) {
if (order == null) {
order = "";
}
+ Object object = this.getOrderReferenceValue(filter);
+ if (null == object) {
+ query.append(this.orderTerm(searchTableNameAlias, TEXTVALUE, order, grouped)).append(", ")
+ .append(this.orderTerm(searchTableNameAlias, "datevalue", order, grouped)).append(", ")
+ .append(this.orderTerm(searchTableNameAlias, "numvalue", order, grouped));
+ return;
+ }
+ query.append(this.orderTerm(searchTableNameAlias, this.getAttributeFieldColunm(object), order, grouped));
+ }
+
+ /**
+ * One ORDER BY term over an attribute column. When the body groups, the column belongs to the
+ * joined search table and is not part of the grouping key, so it is reached through an aggregate:
+ * an entity holding several values sorts on the one the requested direction asks for - the lowest
+ * ascending, the highest descending.
+ *
+ * @param searchTableNameAlias The alias of the joined search table.
+ * @param columnName The column holding the attribute value.
+ * @param order The requested direction.
+ * @param grouped True when the body groups by the master id.
+ * @return The term, direction included.
+ */
+ private String orderTerm(String searchTableNameAlias, String columnName, String order, boolean grouped) {
+ StringBuilder term = new StringBuilder();
+ if (grouped) {
+ String aggregate = FieldSearchFilter.DESC_ORDER.equalsIgnoreCase(order) ? "MAX" : "MIN";
+ term.append(aggregate).append("(").append(searchTableNameAlias).append(".").append(columnName).append(")");
+ } else {
+ term.append(searchTableNameAlias).append(".").append(columnName);
+ }
+ return term.append(" ").append(order).toString();
+ }
+
+ /**
+ * The value an ORDER BY on an attribute filter is resolved against. Shared with the select block so
+ * that the projected column and the ordered column are always the same one.
+ */
+ private Object getOrderReferenceValue(EntitySearchFilter filter) {
Object object = filter.getValue();
if (object == null) {
object = filter.getStart();
@@ -481,14 +698,7 @@ private void addAttributeOrderQueryBlock(String searchTableNameAlias, StringBuff
if (object == null) {
object = filter.getEnd();
}
- if (null == object) {
- query.append(searchTableNameAlias).append(".textvalue ").append(order).append(", ")
- .append(searchTableNameAlias).append(".datevalue ").append(order).append(", ")
- .append(searchTableNameAlias).append(".numvalue ").append(order);
- return;
- }
- query.append(searchTableNameAlias).append(".").append(this.getAttributeFieldColunm(object)).append(" ");
- query.append(order);
+ return object;
}
private String getAttributeFieldColunm(EntitySearchFilter filter) {
@@ -512,13 +722,13 @@ private String getAttributeFieldColunm(Object attributeValue) {
if (null == attributeValue) {
columnName = null;
} else if (attributeValue instanceof String) {
- columnName = "textvalue";
+ columnName = TEXTVALUE;
} else if (attributeValue instanceof Date) {
columnName = "datevalue";
} else if (attributeValue instanceof BigDecimal) {
columnName = "numvalue";
} else if (attributeValue instanceof Boolean) {
- columnName = "textvalue";
+ columnName = TEXTVALUE;
}
return columnName;
}
diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java
index bc60d8ebc9..c90772d96b 100644
--- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java
+++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java
@@ -15,6 +15,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import com.agiletec.aps.system.services.group.Group;
import com.agiletec.aps.system.services.role.Role;
@@ -42,6 +43,15 @@ public class AuthorizationDAO extends AbstractSearcherDAO implements IAuthorizat
public static final int BATCH_SIZE_FLUSH = 50;
private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(AuthorizationDAO.class);
+
+ private static final String USERNAME = "username";
+
+ /** The columns of authusergrouprole a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "id",
+ USERNAME,
+ "groupname",
+ "rolename");
@Override
public void addUserAuthorization(String username, Authorization authorization) {
@@ -234,7 +244,7 @@ public boolean externalAuthSyncCheck(final String username, final Long iat) {
try (ResultSet rs = selectStmt.executeQuery()) {
if (rs.next()) {
- userId = rs.getString("username");
+ userId = rs.getString(USERNAME);
lastSyncedIat = rs.getLong("iat");
}
}
@@ -274,7 +284,7 @@ public void externalAuthSync(final String username, final Long iat,
try (ResultSet rs = selectStmt.executeQuery()) {
if (rs.next()) {
- usernameTracked = rs.getString("username");
+ usernameTracked = rs.getString(USERNAME);
oldIat = rs.getLong("iat");
}
}
@@ -494,8 +504,8 @@ public int doBatchDeletion(Connection conn, long epochSeconds, int batchSize) th
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
@@ -505,7 +515,7 @@ protected String getMasterTableName() {
@Override
protected String getMasterTableIdFieldName() {
- return "username";
+ return USERNAME;
}
private final String ADD_AUTHORIZATION =
diff --git a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java
index df4edafed9..7e0c63832b 100644
--- a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java
+++ b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java
@@ -23,6 +23,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.entando.entando.ent.util.EntLogging.EntLogger;
import org.entando.entando.ent.util.EntLogging.EntLogFactory;
@@ -33,6 +34,11 @@
public class GroupDAO extends AbstractSearcherDAO implements IGroupDAO {
private static final EntLogger logger = EntLogFactory.getSanitizedLogger(GroupDAO.class);
+
+ /** The columns of authgroups a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "groupname",
+ "descr");
@Override
public int countGroups(FieldSearchFilter[] filters) {
@@ -160,8 +166,8 @@ public void deleteGroup(String groupName) {
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
diff --git a/engine/src/main/java/com/agiletec/aps/system/services/pagemodel/PageModelDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/pagemodel/PageModelDAO.java
index 13474df09c..872b0eac3f 100644
--- a/engine/src/main/java/com/agiletec/aps/system/services/pagemodel/PageModelDAO.java
+++ b/engine/src/main/java/com/agiletec/aps/system/services/pagemodel/PageModelDAO.java
@@ -23,6 +23,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.entando.entando.ent.exception.EntException;
import org.apache.commons.lang3.StringUtils;
import org.entando.entando.aps.system.services.widgettype.IWidgetTypeManager;
@@ -36,6 +37,14 @@
public class PageModelDAO extends AbstractSearcherDAO implements IPageModelDAO {
private static final EntLogger logger = EntLogFactory.getSanitizedLogger(PageModelDAO.class);
+
+ /** The columns of pagemodels a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "code",
+ "descr",
+ "frames",
+ "plugincode",
+ "templategui");
@Override
public int count(FieldSearchFilter[] filters) {
@@ -191,8 +200,8 @@ public void deleteModel(String code) {
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
diff --git a/engine/src/main/java/org/entando/entando/aps/system/services/actionlog/ActionLogDAO.java b/engine/src/main/java/org/entando/entando/aps/system/services/actionlog/ActionLogDAO.java
index 637a21b393..5bbe0bd1e8 100644
--- a/engine/src/main/java/org/entando/entando/aps/system/services/actionlog/ActionLogDAO.java
+++ b/engine/src/main/java/org/entando/entando/aps/system/services/actionlog/ActionLogDAO.java
@@ -33,6 +33,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import com.agiletec.aps.system.services.group.Group;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -51,6 +52,25 @@ public class ActionLogDAO extends AbstractSearcherDAO implements IActionLogDAO {
private final EntLogger logger = EntLogFactory.getSanitizedLogger(getClass());
+ private static final String USERNAME = "username";
+ private static final String ACTIONDATE = "actiondate";
+ private static final String NAMESPACE = "namespace";
+ private static final String ACTIONNAME = "actionname";
+ private static final String PARAMETERS = "parameters";
+ private static final String ACTIVITYSTREAMINFO = "activitystreaminfo";
+ private static final String UPDATEDATE = "updatedate";
+
+ /** The columns of actionlogrecords a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "id",
+ USERNAME,
+ ACTIONDATE,
+ NAMESPACE,
+ ACTIONNAME,
+ PARAMETERS,
+ ACTIVITYSTREAMINFO,
+ UPDATEDATE);
+
private static final String ADD_ACTION_RECORD
= "INSERT INTO actionlogrecords ( id, username, actiondate, namespace, actionname, parameters, activitystreaminfo, updatedate) "
+ "VALUES ( ? , ? , ? , ? , ? , ? , ? , ? )";
@@ -241,7 +261,7 @@ private PreparedStatement buildStatement(FieldSearchFilter[] filters, Collection
String query = (isSelectMax) ? this.createQueryStringForSelectMax(filters, groupCodes): this.createQueryString(filters, groupCodes);
PreparedStatement stat = null;
try {
- stat = conn.prepareStatement(query);
+ stat = this.prepareStatement(conn, query);
int index = 0;
index = this.addMetadataFieldFilterStatementBlock(filters, index, stat);
index = this.addGroupStatementBlock(groupCodes, index, stat);
@@ -328,22 +348,22 @@ protected FieldSearchFilter[] createFilters(IActionLogRecordSearchBean searchBea
}
String username = searchBean.getUsername();
if (null != username && username.trim().length() > 0) {
- FieldSearchFilter filter = new FieldSearchFilter("username", this.extractSearchValues(username), true);
+ FieldSearchFilter filter = new FieldSearchFilter(USERNAME, this.extractSearchValues(username), true);
filters = super.addFilter(filters, filter);
}
String namespace = searchBean.getNamespace();
if (null != namespace && namespace.trim().length() > 0) {
- FieldSearchFilter filter = new FieldSearchFilter("namespace", this.extractSearchValues(namespace), true);
+ FieldSearchFilter filter = new FieldSearchFilter(NAMESPACE, this.extractSearchValues(namespace), true);
filters = super.addFilter(filters, filter);
}
String actionName = searchBean.getActionName();
if (null != actionName && actionName.trim().length() > 0) {
- FieldSearchFilter filter = new FieldSearchFilter("actionname", this.extractSearchValues(actionName), true);
+ FieldSearchFilter filter = new FieldSearchFilter(ACTIONNAME, this.extractSearchValues(actionName), true);
filters = super.addFilter(filters, filter);
}
String parameters = searchBean.getParams();
if (null != parameters && parameters.trim().length() > 0) {
- FieldSearchFilter filter = new FieldSearchFilter("parameters", this.extractSearchValues(parameters), true);
+ FieldSearchFilter filter = new FieldSearchFilter(PARAMETERS, this.extractSearchValues(parameters), true);
filters = super.addFilter(filters, filter);
}
Date startCreation = searchBean.getStartCreation();
@@ -351,7 +371,7 @@ protected FieldSearchFilter[] createFilters(IActionLogRecordSearchBean searchBea
if (null != startCreation || null != endCreation) {
Timestamp tsStart = (null != startCreation) ? new Timestamp(startCreation.getTime()) : null;
Timestamp tsEnd = (null != endCreation) ? new Timestamp(endCreation.getTime()) : null;
- FieldSearchFilter filter = new FieldSearchFilter("actiondate", tsStart, tsEnd);
+ FieldSearchFilter filter = new FieldSearchFilter(ACTIONDATE, tsStart, tsEnd);
filter.setOrder(FieldSearchFilter.Order.DESC);
filters = super.addFilter(filters, filter);
}
@@ -360,12 +380,12 @@ protected FieldSearchFilter[] createFilters(IActionLogRecordSearchBean searchBea
if (null != startUpdate || null != endUpdate) {
Timestamp tsStart = (null != startUpdate) ? new Timestamp(startUpdate.getTime()) : null;
Timestamp tsEnd = (null != endUpdate) ? new Timestamp(endUpdate.getTime()) : null;
- FieldSearchFilter filter = new FieldSearchFilter("updatedate", tsStart, tsEnd);
+ FieldSearchFilter filter = new FieldSearchFilter(UPDATEDATE, tsStart, tsEnd);
filter.setOrder(FieldSearchFilter.Order.DESC);
filters = super.addFilter(filters, filter);
}
if (searchBean instanceof IActivityStreamSearchBean) {
- FieldSearchFilter filter = new FieldSearchFilter("activitystreaminfo");
+ FieldSearchFilter filter = new FieldSearchFilter(ACTIVITYSTREAMINFO);
filters = super.addFilter(filters, filter);
}
}
@@ -392,15 +412,15 @@ public ActionLogRecord getActionRecord(int id) {
if (res.next()) {
actionRecord = new ActionLogRecord();
actionRecord.setId(id);
- Timestamp actionDate = res.getTimestamp("actiondate");
+ Timestamp actionDate = res.getTimestamp(ACTIONDATE);
actionRecord.setActionDate(new Date(actionDate.getTime()));
- Timestamp updateDate = res.getTimestamp("updatedate");
+ Timestamp updateDate = res.getTimestamp(UPDATEDATE);
actionRecord.setUpdateDate(new Date(updateDate.getTime()));
- actionRecord.setActionName(res.getString("actionname"));
- actionRecord.setNamespace(res.getString("namespace"));
- actionRecord.setParameters(res.getString("parameters"));
- actionRecord.setUsername(res.getString("username"));
- String asiXml = res.getString("activitystreaminfo");
+ actionRecord.setActionName(res.getString(ACTIONNAME));
+ actionRecord.setNamespace(res.getString(NAMESPACE));
+ actionRecord.setParameters(res.getString(PARAMETERS));
+ actionRecord.setUsername(res.getString(USERNAME));
+ String asiXml = res.getString(ACTIVITYSTREAMINFO);
if (null != asiXml && asiXml.trim().length() > 0) {
ActivityStreamInfo asi = ActivityStreamInfoDOM.unmarshalInfo(asiXml);
actionRecord.setActivityStreamInfo(asi);
@@ -466,8 +486,8 @@ protected String getMasterTableIdFieldName() {
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
@@ -520,9 +540,9 @@ private void extractRecordToDelete(String groupName,
ResultSet result = null;
try {
List idList = new ArrayList<>();
- FieldSearchFilter filter1 = new FieldSearchFilter("actiondate");
+ FieldSearchFilter filter1 = new FieldSearchFilter(ACTIONDATE);
filter1.setOrder(FieldSearchFilter.Order.DESC);
- FieldSearchFilter filter2 = new FieldSearchFilter("activitystreaminfo");
+ FieldSearchFilter filter2 = new FieldSearchFilter(ACTIVITYSTREAMINFO);
FieldSearchFilter[] filters = {filter1, filter2};
List groupCodes = new ArrayList<>();
groupCodes.add(groupName);
diff --git a/engine/src/main/java/org/entando/entando/aps/system/services/guifragment/GuiFragmentDAO.java b/engine/src/main/java/org/entando/entando/aps/system/services/guifragment/GuiFragmentDAO.java
index 7d840be09b..75eaaa08a9 100644
--- a/engine/src/main/java/org/entando/entando/aps/system/services/guifragment/GuiFragmentDAO.java
+++ b/engine/src/main/java/org/entando/entando/aps/system/services/guifragment/GuiFragmentDAO.java
@@ -22,6 +22,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.apache.commons.lang3.StringUtils;
import org.entando.entando.ent.util.EntLogging.EntLogger;
import org.entando.entando.ent.util.EntLogging.EntLogFactory;
@@ -33,6 +34,17 @@ public class GuiFragmentDAO extends AbstractSearcherDAO implements IGuiFragmentD
private static final EntLogger logger = EntLogFactory.getSanitizedLogger(GuiFragmentDAO.class);
+ private static final String PLUGINCODE = "plugincode";
+
+ /** The columns of guifragment a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "code",
+ "widgettypecode",
+ PLUGINCODE,
+ "gui",
+ "defaultgui",
+ "locked");
+
private static final String ADD_GUIFRAGMENT = "INSERT INTO guifragment (code, widgettypecode, plugincode, gui, locked ) VALUES (? , ? , ? , ? , ?)";
private static final String UPDATE_GUIFRAGMENT = "UPDATE guifragment SET widgettypecode = ?, plugincode = ? , gui = ? WHERE code = ? ";
@@ -44,8 +56,8 @@ public class GuiFragmentDAO extends AbstractSearcherDAO implements IGuiFragmentD
private static final String LOAD_GUIFRAGMENT_PLUGIN_CODES = "SELECT plugincode FROM guifragment";
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
@@ -230,7 +242,7 @@ protected GuiFragment buildGuiFragmentFromRes(ResultSet res) {
guiFragment = new GuiFragment();
guiFragment.setCode(res.getString("code"));
guiFragment.setWidgetTypeCode(res.getString("widgettypecode"));
- guiFragment.setPluginCode(res.getString("plugincode"));
+ guiFragment.setPluginCode(res.getString(PLUGINCODE));
guiFragment.setGui(res.getString("gui"));
guiFragment.setDefaultGui(res.getString("defaultgui"));
Integer locked = res.getInt("locked");
@@ -252,7 +264,7 @@ public List loadGuiFragmentPluginCodes() {
stat = conn.prepareStatement(LOAD_GUIFRAGMENT_PLUGIN_CODES);
res = stat.executeQuery();
while (res.next()) {
- String code = res.getString("plugincode");
+ String code = res.getString(PLUGINCODE);
if (StringUtils.isNotEmpty(code) && !codes.contains(code)) {
codes.add(code);
}
diff --git a/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuth2TokenDAO.java b/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuth2TokenDAO.java
index bf03304443..a4c72c3000 100644
--- a/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuth2TokenDAO.java
+++ b/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuth2TokenDAO.java
@@ -15,6 +15,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.entando.entando.ent.util.EntLogging.EntLogger;
import org.entando.entando.ent.util.EntLogging.EntLogFactory;
@@ -41,6 +42,21 @@ public class OAuth2TokenDAO extends AbstractSearcherDAO implements IOAuth2TokenD
private static final EntLogger logger = EntLogFactory.getSanitizedLogger(OAuth2TokenDAO.class);
+ private static final String CLIENTID = "clientid";
+ private static final String EXPIRESIN = "expiresin";
+ private static final String REFRESHTOKEN = "refreshtoken";
+ private static final String GRANTTYPE = "granttype";
+ private static final String LOCALUSER = "localuser";
+
+ /** The columns of api_oauth_tokens a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "accesstoken",
+ CLIENTID,
+ EXPIRESIN,
+ REFRESHTOKEN,
+ GRANTTYPE,
+ LOCALUSER);
+
private static final String ERROR_REMOVE_ACCESS_TOKEN = "Error while remove access token";
private static final String INSERT_TOKEN = "INSERT INTO api_oauth_tokens (accesstoken, clientid, expiresin, refreshtoken, granttype, localuser) VALUES (? , ? , ? , ? , ?, ?)";
@@ -60,8 +76,8 @@ public class OAuth2TokenDAO extends AbstractSearcherDAO implements IOAuth2TokenD
private static final String DELETE_TOKEN_BY_REFRESH = DELETE_TOKEN_PREFIX + "WHERE refreshtoken = ? ";
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
@@ -79,15 +95,15 @@ public List findTokensByClientIdAndUserName(String clientId,
if (StringUtils.isBlank(clientId) && StringUtils.isBlank(username)) {
throw new RuntimeException("clientId and username cannot both be null");
}
- FieldSearchFilter expirationFilter = new FieldSearchFilter("expiresin");
+ FieldSearchFilter expirationFilter = new FieldSearchFilter(EXPIRESIN);
expirationFilter.setOrder(FieldSearchFilter.Order.ASC);
FieldSearchFilter[] filters = {expirationFilter};
if (!StringUtils.isBlank(clientId)) {
- FieldSearchFilter clientIdFilter = new FieldSearchFilter("clientid", clientId, true);
+ FieldSearchFilter clientIdFilter = new FieldSearchFilter(CLIENTID, clientId, true);
filters = ArrayUtils.add(filters, clientIdFilter);
}
if (!StringUtils.isBlank(username)) {
- FieldSearchFilter usernameFilter = new FieldSearchFilter("localuser", username, true);
+ FieldSearchFilter usernameFilter = new FieldSearchFilter(LOCALUSER, username, true);
filters = ArrayUtils.add(filters, usernameFilter);
}
List accessTokens = new ArrayList<>();
@@ -122,20 +138,20 @@ protected OAuth2AccessToken getAccessToken(final String token, Connection conn)
stat.setString(1, token);
res = stat.executeQuery();
if (res.next()) {
- String refreshTokenValue = res.getString("refreshtoken");
+ String refreshTokenValue = res.getString(REFRESHTOKEN);
OAuth2RefreshToken refreshToken = refreshTokenValue != null ?
new OAuth2RefreshToken(refreshTokenValue, java.time.Instant.now()) : null;
- Timestamp timestamp = res.getTimestamp("expiresin");
+ Timestamp timestamp = res.getTimestamp(EXPIRESIN);
Date expiration = new Date(timestamp.getTime());
// Use the immutable constructor pattern
accessToken = new OAuth2AccessTokenImpl(
token,
expiration.toInstant(),
- res.getString("clientid"),
- res.getString("granttype"),
- res.getString("localuser"),
+ res.getString(CLIENTID),
+ res.getString(GRANTTYPE),
+ res.getString(LOCALUSER),
refreshToken
);
}
@@ -283,7 +299,7 @@ public void deleteExpiredToken(int expirationTime) {
@Override
public OAuth2RefreshToken readRefreshToken(String tokenValue) {
- FieldSearchFilter filter = new FieldSearchFilter("refreshtoken", tokenValue, true);
+ FieldSearchFilter filter = new FieldSearchFilter(REFRESHTOKEN, tokenValue, true);
FieldSearchFilter[] filters = {filter};
List accessTokens = super.searchId(filters);
if (null != accessTokens && accessTokens.size() > 0) {
@@ -304,9 +320,9 @@ public OAuth2Authorization readAuthenticationForRefreshToken(OAuth2RefreshToken
stat.setString(1, refreshToken.getTokenValue());
res = stat.executeQuery();
if (res.next()) {
- String username = res.getString("localuser");
- String clientId = res.getString("clientid");
- String grantType = res.getString("granttype");
+ String username = res.getString(LOCALUSER);
+ String clientId = res.getString(CLIENTID);
+ String grantType = res.getString(GRANTTYPE);
// In Spring Security 6.x OAuth2Authorization, we need to build a more complete structure
// For now, we'll return null and log a warning since this method should be handled
diff --git a/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuthConsumerDAO.java b/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuthConsumerDAO.java
index fb5caf2695..a2652ffd1b 100644
--- a/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuthConsumerDAO.java
+++ b/engine/src/main/java/org/entando/entando/aps/system/services/oauth2/OAuthConsumerDAO.java
@@ -22,6 +22,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.entando.entando.ent.exception.EntException;
import java.sql.Types;
import java.util.ArrayList;
@@ -40,6 +41,20 @@ public class OAuthConsumerDAO extends AbstractSearcherDAO implements IOAuthConsu
private static final EntLogger logger = EntLogFactory.getSanitizedLogger(OAuthConsumerDAO.class);
+ private static final String CONSUMERKEY = "consumerkey";
+
+ /** The columns of api_oauth_consumers a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ CONSUMERKEY,
+ "consumersecret",
+ "name",
+ "description",
+ "callbackurl",
+ "scope",
+ "authorizedgranttypes",
+ "expirationdate",
+ "issueddate");
+
private static final String SELECT_CONSUMER
= "SELECT consumerkey, consumersecret, name, description, callbackurl, scope, authorizedgranttypes, expirationdate, issueddate "
+ "FROM api_oauth_consumers WHERE consumerkey = ? ";
@@ -134,7 +149,7 @@ public List getConsumers(FieldSearchFilter>[] filters) {
private ConsumerRecordVO consumerFromResultSet(ResultSet res) throws SQLException {
ConsumerRecordVO consumer = new ConsumerRecordVO();
- consumer.setKey(res.getString("consumerkey"));
+ consumer.setKey(res.getString(CONSUMERKEY));
consumer.setSecret(res.getString("consumersecret"));
consumer.setCallbackUrl(res.getString("callbackurl"));
consumer.setName(res.getString("name"));
@@ -237,7 +252,7 @@ public void deleteConsumer(String clientId) {
@Override
protected String getMasterTableIdFieldName() {
- return "consumerkey";
+ return CONSUMERKEY;
}
@Override
@@ -246,8 +261,8 @@ protected String getMasterTableName() {
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
}
diff --git a/engine/src/main/java/org/entando/entando/aps/system/services/userprofile/UserProfileSearcherDAO.java b/engine/src/main/java/org/entando/entando/aps/system/services/userprofile/UserProfileSearcherDAO.java
index 43ab6d449a..900fa4d1b8 100644
--- a/engine/src/main/java/org/entando/entando/aps/system/services/userprofile/UserProfileSearcherDAO.java
+++ b/engine/src/main/java/org/entando/entando/aps/system/services/userprofile/UserProfileSearcherDAO.java
@@ -17,6 +17,7 @@
import org.entando.entando.aps.system.services.userprofile.model.UserProfileRecord;
+import com.agiletec.aps.system.common.SearchableFields;
import com.agiletec.aps.system.common.entity.AbstractEntitySearcherDAO;
import com.agiletec.aps.system.common.entity.IEntityManager;
import com.agiletec.aps.system.common.entity.model.ApsEntityRecord;
@@ -27,12 +28,22 @@
*/
public class UserProfileSearcherDAO extends AbstractEntitySearcherDAO {
+ private static final String USERNAME = "username";
+ private static final String PROFILETYPE = "profiletype";
+
+ /** The search keys this searcher accepts. username is the master table's id column. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ USERNAME,
+ "publicprofile")
+ .alias(IEntityManager.ENTITY_ID_FILTER_KEY, USERNAME)
+ .alias(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY, PROFILETYPE);
+
@Override
protected ApsEntityRecord createRecord(ResultSet result) throws Throwable {
UserProfileRecord record = new UserProfileRecord();
- record.setId(result.getString("username"));
+ record.setId(result.getString(USERNAME));
record.setXml(result.getString("profilexml"));
- record.setTypeCode(result.getString("profiletype"));
+ record.setTypeCode(result.getString(PROFILETYPE));
record.setPublicProfile(result.getInt("publicprofile") == 1);
return record;
}
@@ -44,12 +55,12 @@ protected String getEntityMasterTableName() {
@Override
protected String getEntityMasterTableIdFieldName() {
- return "username";
+ return USERNAME;
}
@Override
protected String getEntityMasterTableIdTypeFieldName() {
- return "profiletype";
+ return PROFILETYPE;
}
@Override
@@ -59,7 +70,7 @@ protected String getEntitySearchTableName() {
@Override
protected String getEntitySearchTableIdFieldName() {
- return "username";
+ return USERNAME;
}
@Override
@@ -69,22 +80,12 @@ protected String getEntityAttributeRoleTableName() {
@Override
protected String getEntityAttributeRoleTableIdFieldName() {
- return "username";
+ return USERNAME;
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- if (metadataFieldKey.equalsIgnoreCase("username")) {
- return this.getEntityMasterTableIdFieldName();
- } else if (metadataFieldKey.equals(IEntityManager.ENTITY_ID_FILTER_KEY)) {
- return this.getEntityMasterTableIdFieldName();
- } else if (metadataFieldKey.equals(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY)) {
- return this.getEntityMasterTableIdTypeFieldName();
- } else if (metadataFieldKey.equals(IUserProfileManager.PUBLIC_PROFILE_FILTER_KEY)) {
- return "publicprofile";
- } else {
- throw new RuntimeException("Key '" + metadataFieldKey + "' not recognized");
- }
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
}
diff --git a/engine/src/test/java/com/agiletec/ConfigTestUtils.java b/engine/src/test/java/com/agiletec/ConfigTestUtils.java
index 58905717aa..d1837d05a9 100644
--- a/engine/src/test/java/com/agiletec/ConfigTestUtils.java
+++ b/engine/src/test/java/com/agiletec/ConfigTestUtils.java
@@ -169,6 +169,7 @@ private void createDatasource(String dsNameControlKey, InitialContext builder, P
ds.setMaxTotal(12);
ds.setMaxIdle(4);
ds.setDriverClassName(className);
+ this.applySessionInitSql(ds, className);
bindOrRebind(builder, "java:comp/env/jdbc/" + beanName, ds);
logger.debug("created datasource " + beanName);
} catch (Throwable t) {
@@ -176,6 +177,23 @@ private void createDatasource(String dsNameControlKey, InitialContext builder, P
}
}
+ /**
+ * Oracle parses a date literal against the session NLS_DATE_FORMAT, which defaults to DD-MON-RR, while
+ * the Liquibase fixtures render dates as ISO literals. Without this every suite fails at fixture load
+ * with ORA-01843. The statements run on each physical connection the pool opens, which is the scope
+ * the fixtures and the DAOs both need.
+ *
+ * @param ds The datasource being built.
+ * @param driverClassName The driver of that datasource.
+ */
+ private void applySessionInitSql(BasicDataSource ds, String driverClassName) {
+ if (null != driverClassName && driverClassName.toLowerCase().contains("oracle")) {
+ ds.setConnectionInitSqls(Arrays.asList(
+ "ALTER SESSION SET NLS_DATE_FORMAT='YYYY-MM-DD HH24:MI:SS'",
+ "ALTER SESSION SET NLS_TIMESTAMP_FORMAT='YYYY-MM-DD HH24:MI:SS.FF'"));
+ }
+ }
+
/**
* Restituisce l'insieme dei file di configurazione dei bean definiti nel
* sistema. Il metodo và esteso nel caso si inseriscano file di
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/QueryCapture.java b/engine/src/test/java/com/agiletec/aps/system/common/QueryCapture.java
new file mode 100644
index 0000000000..dd3845dafa
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/QueryCapture.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package com.agiletec.aps.system.common;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.sql.DataSource;
+
+/**
+ * The SQL a searcher DAO hands to the driver, captured without a database.
+ *
+ * Every searcher reaches the driver through {@link AbstractSearcherDAO#prepareStatement}, so
+ * calling a DAO's own search or count method against this datasource yields the generated query and
+ * nothing else: the statement records its parameters into a stub and the result set is always
+ * empty, which leaves counts at zero and id lists empty.
+ */
+public final class QueryCapture {
+
+ private final List queries = new ArrayList<>();
+ private final DataSource dataSource;
+
+ public QueryCapture() {
+ try {
+ ResultSet emptyResult = mock(ResultSet.class);
+ when(emptyResult.next()).thenReturn(false);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(statement.executeQuery()).thenReturn(emptyResult);
+ Connection connection = mock(Connection.class);
+ when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
+ this.queries.add(invocation.getArgument(0));
+ return statement;
+ });
+ this.dataSource = mock(DataSource.class);
+ when(this.dataSource.getConnection()).thenReturn(connection);
+ } catch (SQLException e) {
+ throw new IllegalStateException("the stubs above cannot throw", e);
+ }
+ }
+
+ /**
+ * Wire a DAO to this capture. The driver class name decides the paging syntax, and there is no
+ * real datasource to read it from.
+ *
+ * @param dao The DAO under test.
+ * @param driverClassName The JDBC driver the DAO should generate for.
+ * @param The DAO type.
+ * @return The same DAO, wired.
+ */
+ public T wire(T dao, String driverClassName) {
+ dao.setDataSource(this.dataSource);
+ dao.setDataSourceClassName(driverClassName);
+ return dao;
+ }
+
+ public DataSource getDataSource() {
+ return this.dataSource;
+ }
+
+ public List getQueries() {
+ return Collections.unmodifiableList(this.queries);
+ }
+
+ /**
+ * @return The only query captured so far.
+ */
+ public String single() {
+ if (this.queries.size() != 1) {
+ throw new IllegalStateException("expected exactly one query, captured " + this.queries);
+ }
+ return this.queries.get(0);
+ }
+
+ public void clear() {
+ this.queries.clear();
+ }
+
+}
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/QueryLimitResolverTest.java b/engine/src/test/java/com/agiletec/aps/system/common/QueryLimitResolverTest.java
index 9d640357e4..a2130d5b70 100644
--- a/engine/src/test/java/com/agiletec/aps/system/common/QueryLimitResolverTest.java
+++ b/engine/src/test/java/com/agiletec/aps/system/common/QueryLimitResolverTest.java
@@ -1,12 +1,13 @@
package com.agiletec.aps.system.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.when;
import org.apache.commons.dbcp2.BasicDataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
-import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
@@ -51,8 +52,25 @@ void testOracleDriver() throws Exception {
" OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY ");
}
+ /**
+ * A vendor the resolver does not know fails on the first paginated request, at runtime, with no
+ * compile-time signal. Pinned as current behaviour, not as desired behaviour.
+ */
+ @Test
+ void testUnknownDriver() {
+ String driverClassName = "com.example.UnknownDriver";
+ when(dataSource.getDriverClassName()).thenReturn(driverClassName);
+
+ FieldSearchFilter filter = new FieldSearchFilter();
+ filter.setLimit(1);
+ filter.setOffset(0);
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> QueryLimitResolver.createLimitBlock(filter, dataSource, driverClassName));
+ }
+
private void testCreateLimitBlock(String driverClassName, String expected) throws Exception {
- Mockito.when(dataSource.getDriverClassName()).thenReturn(driverClassName);
+ when(dataSource.getDriverClassName()).thenReturn(driverClassName);
FieldSearchFilter filter = new FieldSearchFilter();
filter.setLimit(1);
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/SearcherDaoQueryShapeTest.java b/engine/src/test/java/com/agiletec/aps/system/common/SearcherDaoQueryShapeTest.java
new file mode 100644
index 0000000000..907e09f430
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/SearcherDaoQueryShapeTest.java
@@ -0,0 +1,530 @@
+/*
+ * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package com.agiletec.aps.system.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.agiletec.aps.system.common.entity.IEntityManager;
+import com.agiletec.aps.system.common.entity.model.EntitySearchFilter;
+import com.agiletec.aps.system.services.group.GroupDAO;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Stream;
+import org.entando.entando.aps.system.services.actionlog.ActionLogDAO;
+import org.entando.entando.aps.system.services.actionlog.model.ActionLogRecordSearchBean;
+import org.entando.entando.aps.system.services.guifragment.GuiFragmentDAO;
+import org.entando.entando.aps.system.services.oauth2.OAuthConsumerDAO;
+import org.entando.entando.aps.system.services.userprofile.UserProfileSearcherDAO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The shape of the SQL the searcher DAOs generate, asserted without a database.
+ *
+ * The count and the list are one body: the count query is the list query's body wrapped, so the
+ * two cannot report different row sets. Execution tests cannot see that - a wrong projection yields
+ * a valid query returning a wrong number - which is what these assertions are for.
+ *
+ * @see QueryCapture
+ */
+class SearcherDaoQueryShapeTest {
+
+ private static final String DERBY = "org.apache.derby.jdbc.EmbeddedDriver";
+
+ private QueryCapture capture;
+
+ @BeforeEach
+ void setUp() {
+ this.capture = new QueryCapture();
+ }
+
+ // ---------------------------------------------------------------- the wrapper
+
+ @Test
+ void countQuery_opensAndClosesTheCountBlockExactlyOnce() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+
+ dao.countGroups(new FieldSearchFilter[]{descriptionLike()});
+
+ String query = this.capture.single();
+ assertEquals(1, SqlShape.occurrences(query, SqlShape.COUNT_PREFIX));
+ assertEquals(1, SqlShape.occurrences(query, SqlShape.COUNT_SUFFIX));
+ }
+
+ @Test
+ void listQuery_carriesNoCountBlock() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+
+ dao.searchGroups(new FieldSearchFilter[]{descriptionLike()});
+
+ String query = this.capture.single();
+ assertEquals(0, SqlShape.occurrences(query, SqlShape.COUNT_PREFIX));
+ assertEquals(0, SqlShape.occurrences(query, SqlShape.COUNT_SUFFIX));
+ }
+
+ // ------------------------------------------------- join-free searchers: no DISTINCT
+
+ /**
+ * A searcher whose count queries the master table alone cannot multiply a row, so its count body
+ * is a plain select: a derived table with no DISTINCT, aggregate or LIMIT is merged by the
+ * planner, leaving a count that can be answered from an index.
+ */
+ @Test
+ void joinFreeCount_selectsTheMasterIdWithoutDistinct() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+
+ dao.countGroups(new FieldSearchFilter[]{descriptionLike()});
+
+ assertEquals("SELECT COUNT(*) FROM ( SELECT authgroups.groupname FROM authgroups"
+ + " WHERE UPPER(authgroups.descr) LIKE ? ) counter",
+ SqlShape.normalize(this.capture.single()));
+ }
+
+ @Test
+ void actionLogCount_selectsTheMasterIdWithoutDistinct() {
+ ActionLogDAO dao = this.capture.wire(new ActionLogDAO(), DERBY);
+ ActionLogRecordSearchBean searchBean = new ActionLogRecordSearchBean();
+ searchBean.setUsername("admin");
+
+ dao.countActionLogRecords(searchBean);
+
+ String query = this.capture.single();
+ assertFalse(SqlShape.isDistinct(query), query);
+ assertEquals(List.of("actionlogrecords.id"), SqlShape.selectedColumns(query));
+ assertEquals(List.of(), SqlShape.joinedTables(query));
+ }
+
+ /**
+ * The base list query is not distinct, so it is free to order on a column it does not project.
+ * The rule that every ordered column has to be in the select list belongs to the distinct
+ * searchers below.
+ */
+ @Test
+ void joinFreeList_ordersOnAColumnItDoesNotProject() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+
+ dao.searchGroups(new FieldSearchFilter[]{ordered(descriptionLike(), FieldSearchFilter.ASC_ORDER)});
+
+ String query = this.capture.single();
+ assertFalse(SqlShape.isDistinct(query), query);
+ assertEquals(List.of("authgroups.groupname"), SqlShape.selectedColumns(query));
+ assertEquals(List.of("authgroups.descr", "authgroups.groupname"), SqlShape.orderedColumns(query));
+ }
+
+ // ----------------------------------------------- entity searchers: one body, distinct
+
+ @Test
+ void entityCount_isTheListBodyWrapped() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+ EntitySearchFilter[] filters = {attributeLike(), orderedMetadata()};
+
+ dao.count(filters);
+ String countQuery = this.capture.single();
+ this.capture.clear();
+ dao.searchId(filters);
+ String listQuery = this.capture.single();
+
+ assertEquals(SqlShape.listBody(listQuery), SqlShape.countBody(countQuery));
+ }
+
+ @Test
+ void entitySelectBlock_isDistinct() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{attributeLike()});
+
+ String query = this.capture.single();
+ assertTrue(SqlShape.isDistinct(query), query);
+ assertEquals(List.of("authuserprofilesearch"), SqlShape.joinedTables(query));
+ }
+
+ /**
+ * A LIKE filter used to add the search table's value column to the select list, aliased and never
+ * read back. Under DISTINCT that column makes the entity distinct row by row, which is the defect
+ * this whole shape exists to prevent.
+ */
+ @Test
+ void entityListQuery_dropsTheColumnsProjectedOnlyForALikeFilter() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{attributeLike()});
+
+ String query = this.capture.single();
+ assertEquals(List.of("authuserprofiles.username"), SqlShape.selectedColumns(query));
+ assertFalse(SqlShape.normalize(query).contains("AS textvalue"), query);
+ }
+
+ /**
+ * Every ordered column has to be reachable by the engine: a plain column reference must be in the
+ * select list - Derby and PostgreSQL reject an ORDER BY outside it under DISTINCT - while an
+ * aggregate must not be, because projecting it is exactly what would stop the entity collapsing.
+ *
+ * Covers all five resolutions of the order block, including the two that resolve to three
+ * columns at once (an allowed-values filter and a filter carrying no value).
+ */
+ @ParameterizedTest(name = "ordered by {0}")
+ @MethodSource("orderedFilters")
+ void listQuery_makesEveryOrderedColumnReachable(String description, EntitySearchFilter orderFilter) {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{orderFilter});
+
+ String query = this.capture.single();
+ List projected = SqlShape.selectedColumns(query);
+ List grouped = SqlShape.groupedColumns(query);
+ for (String term : SqlShape.orderedColumns(query)) {
+ if (SqlShape.isAggregate(term)) {
+ String column = SqlShape.aggregatedColumn(term);
+ assertFalse(projected.contains(column),
+ () -> "aggregated " + column + " but also projected it - " + query);
+ assertFalse(grouped.isEmpty(), () -> "aggregate without a GROUP BY - " + query);
+ } else {
+ assertTrue(projected.contains(term),
+ () -> "ordered by " + term + " but projected " + projected + " - " + query);
+ }
+ }
+ // a grouped body collapses the entity itself; DISTINCT on top would be redundant
+ assertEquals(SqlShape.isGrouped(query), !SqlShape.isDistinct(query), query);
+ }
+
+ /**
+ * The grouping is scoped to the case that needs it. Ordering on metadata alone cannot multiply a
+ * row, so those searches keep the plan, the totals and the row order they had.
+ */
+ @Test
+ void orderingOnMetadataAlone_doesNotGroup() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{attributeLike(), orderedMetadata()});
+
+ String query = this.capture.single();
+ assertFalse(SqlShape.isGrouped(query), query);
+ assertTrue(SqlShape.isDistinct(query), query);
+ }
+
+ /**
+ * Ordering by an attribute groups instead, and the count wraps the grouped body - so it counts
+ * entities rather than joined rows, and its total is exact.
+ */
+ @Test
+ void orderingByAnAttribute_groupsOnTheMasterIdOnBothSides() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+ EntitySearchFilter[] filters = {ordered(attributeLike(), FieldSearchFilter.ASC_ORDER)};
+
+ dao.count(filters);
+ String countQuery = this.capture.single();
+ this.capture.clear();
+ dao.searchId(filters);
+ String listQuery = this.capture.single();
+
+ assertEquals(List.of("authuserprofiles.username"), SqlShape.groupedColumns(countQuery));
+ assertEquals(SqlShape.listBody(listQuery), SqlShape.countBody(countQuery));
+ assertFalse(SqlShape.isDistinct(listQuery), listQuery);
+ assertEquals(List.of("authuserprofiles.username"), SqlShape.selectedColumns(listQuery));
+ }
+
+ /**
+ * A metadata column ordered alongside the attribute is grouped on as well. Derby and Oracle both
+ * reject an un-aggregated column outside the GROUP BY, even one functionally dependent on the
+ * grouping key that MySQL and PostgreSQL accept - so the portable form is the explicit one.
+ */
+ @Test
+ void aMetadataOrderAlongsideAnAttribute_joinsTheGroupingKey() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{ordered(attributeLike(), FieldSearchFilter.ASC_ORDER),
+ orderedMetadata()});
+
+ String query = this.capture.single();
+ assertEquals(List.of("authuserprofiles.username", "authuserprofiles.profiletype"),
+ SqlShape.groupedColumns(query));
+ assertEquals(List.of("authuserprofiles.username", "authuserprofiles.profiletype"),
+ SqlShape.selectedColumns(query));
+ }
+
+ /**
+ * The select-all path loads whole records, has no count paired with it and projects the master
+ * table's CLOB columns. It is never grouped - an aggregate there would have to be matched by a
+ * grouping key for every projected column.
+ */
+ @Test
+ void selectAllPath_isNeverGrouped() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchRecords(new EntitySearchFilter[]{ordered(attributeLike(), FieldSearchFilter.ASC_ORDER)});
+
+ String query = this.capture.single();
+ assertFalse(SqlShape.isGrouped(query), query);
+ assertFalse(SqlShape.normalize(query).contains("MIN("), query);
+ }
+
+ static Stream orderedFilters() {
+ return Stream.of(
+ Arguments.of("a metadata field", orderedMetadata()),
+ Arguments.of("an attribute carrying a value",
+ ordered(new EntitySearchFilter<>("Nome", true, "abc", false), FieldSearchFilter.ASC_ORDER)),
+ Arguments.of("an attribute carrying a range",
+ ordered(new EntitySearchFilter<>("Data", true, new Date(0), new Date()), FieldSearchFilter.ASC_ORDER)),
+ Arguments.of("an attribute carrying allowed values",
+ ordered(new EntitySearchFilter<>("Numero", true,
+ List.of(BigDecimal.ONE, BigDecimal.TEN), false), FieldSearchFilter.DESC_ORDER)),
+ Arguments.of("an attribute carrying no value at all",
+ ordered(new EntitySearchFilter("Nome", true), FieldSearchFilter.ASC_ORDER)));
+ }
+
+ /**
+ * The select-all path loads whole records and has no count paired with it. It must keep its old
+ * shape: contents.workxml and authuserprofiles.profilexml are CLOB, and Derby rejects DISTINCT
+ * over a CLOB.
+ */
+ @Test
+ void selectAllPath_isNotDistinct() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchRecords(new EntitySearchFilter[]{attributeLike()});
+
+ String query = this.capture.single();
+ assertFalse(SqlShape.isDistinct(query), query);
+ assertTrue(SqlShape.normalize(query).startsWith("SELECT authuserprofiles.*"), query);
+ }
+
+ // ---------------------------------------------------------------- the balance guard
+
+ /**
+ * The only remaining route to an unbalanced count block is a subclass writing the markers by
+ * hand. The guard names the DAO that built the query; it does not repair it, and it does not
+ * throw - the database rejects such a query on its own, and hiding that would be worse.
+ */
+ @Test
+ void unbalancedCountBlock_isReportedByNameAndNotRepaired() {
+ UnbalancedGroupDAO dao = this.capture.wire(new UnbalancedGroupDAO(), DERBY);
+ Logger logger = (Logger) LoggerFactory.getLogger(AbstractSearcherDAO.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ dao.countGroups(new FieldSearchFilter[]{descriptionLike()});
+ } finally {
+ logger.detachAppender(appender);
+ }
+
+ String query = this.capture.single();
+ assertEquals(2, SqlShape.occurrences(query, SqlShape.COUNT_PREFIX));
+ assertEquals(1, SqlShape.occurrences(query, SqlShape.COUNT_SUFFIX));
+ List errors = appender.list.stream()
+ .filter(event -> Level.ERROR.equals(event.getLevel()))
+ .map(ILoggingEvent::getFormattedMessage)
+ .toList();
+ assertEquals(1, errors.size(), () -> "expected one report, got " + errors);
+ assertTrue(errors.get(0).contains(UnbalancedGroupDAO.class.getName()), errors.get(0));
+ }
+
+ // ---------------------------------------------------------------- order and paging
+
+ /**
+ * ORDER BY and the paging block are appended on the list side only. They are what the count body
+ * must not contain: a count over a paged body would count one page, and a count that sorted would
+ * pay for a sort nobody reads. The per-vendor syntax of the block itself is covered by
+ * {@link QueryLimitResolverTest}.
+ */
+ @Test
+ void orderAndPagingBelongToTheListQueryOnly() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+ FieldSearchFilter[] filters = {ordered(descriptionLike(), FieldSearchFilter.ASC_ORDER),
+ new FieldSearchFilter(10, 5)};
+
+ dao.searchGroups(filters);
+ String listQuery = this.capture.single();
+ this.capture.clear();
+ dao.countGroups(filters);
+ String countQuery = this.capture.single();
+
+ assertEquals("OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY", SqlShape.pagingBlock(listQuery));
+ assertEquals("", SqlShape.pagingBlock(countQuery));
+ assertFalse(SqlShape.normalize(countQuery).contains("ORDER BY"), countQuery);
+ assertEquals(SqlShape.listBody(listQuery), SqlShape.countBody(countQuery));
+ }
+
+ // ---------------------------------------------------------------- the field whitelist
+
+ /**
+ * A filter key becomes a column name by concatenation, so it is checked against the columns the
+ * searcher accepts. Values are bound as parameters and were never the exposure; keys are.
+ *
+ * The REST layer validates keys against a DTO's fields, but that is a guarantee made far from
+ * here and absent for every non-REST caller - so the searcher does not rely on it.
+ */
+ @Test
+ void aFilterKeyThatIsNotAColumn_neverReachesTheDriver() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+ FieldSearchFilter[] filters = {new FieldSearchFilter<>("descr) OR 1=1 --", "x", true)};
+
+ assertThrows(RuntimeException.class, () -> dao.searchGroups(filters));
+ assertTrue(this.capture.getQueries().isEmpty(),
+ () -> "a query was still handed to the driver: " + this.capture.getQueries());
+ }
+
+ /** The mirror image: a key that does name a column is untouched. */
+ @Test
+ void aFilterKeyThatIsAColumn_isAccepted() {
+ GroupDAO dao = this.capture.wire(new GroupDAO(), DERBY);
+
+ dao.searchGroups(new FieldSearchFilter[]{descriptionLike()});
+
+ assertTrue(SqlShape.normalize(this.capture.single()).contains("UPPER(authgroups.descr)"),
+ this.capture.single());
+ }
+
+ /**
+ * The allowlist is matched without regard to case, because SQL identifiers are folded by every
+ * database the engine supports - ORDER BY guifragment.pluginCode and
+ * ...plugincode are the same column on Derby, PostgreSQL, MySQL and Oracle.
+ *
+ * The keys reaching the DAO are DTO field names, which are camelCase: pluginCode is
+ * an inherited GuiFragmentDtoSmall field, it passes the REST validator, and
+ * GuiFragmentService forwards it without remapping. Matching it exactly would refuse a
+ * query that works.
+ */
+ @Test
+ void aFilterKeyDifferingOnlyByCase_isAccepted() {
+ GuiFragmentDAO dao = this.capture.wire(new GuiFragmentDAO(), DERBY);
+
+ dao.searchGuiFragments(new FieldSearchFilter[]{sortOnly("pluginCode")});
+
+ assertTrue(SqlShape.normalize(this.capture.single()).contains("ORDER BY guifragment.plugincode"),
+ this.capture.single());
+ }
+
+ /**
+ * And what is concatenated is the column as the searcher declares it, not as the caller spelled it,
+ * so caller-supplied text does not reach the query even on the accepting path.
+ */
+ @Test
+ void anAcceptedKey_isEmittedInTheSearchersOwnSpelling() {
+ OAuthConsumerDAO dao = this.capture.wire(new OAuthConsumerDAO(), DERBY);
+
+ dao.getConsumerKeys(new FieldSearchFilter[]{sortOnly("issuedDate")});
+
+ String query = SqlShape.normalize(this.capture.single());
+ assertTrue(query.contains("issueddate"), query);
+ assertFalse(query.contains("issuedDate"), query);
+ }
+
+ /** Case folding is not a way past the allowlist: an unknown key is still refused. */
+ @Test
+ void aFilterKeyThatIsNoColumnInAnyCase_isStillRefused() {
+ GuiFragmentDAO dao = this.capture.wire(new GuiFragmentDAO(), DERBY);
+ FieldSearchFilter[] filters = {sortOnly("PLUGINCODE) OR 1=1 --")};
+
+ assertThrows(RuntimeException.class, () -> dao.searchGuiFragments(filters));
+ assertTrue(this.capture.getQueries().isEmpty(),
+ () -> "a query was still handed to the driver: " + this.capture.getQueries());
+ }
+
+ /**
+ * A searcher whose keys are not its column names declares that as an alias, and the alias resolves
+ * to the column. typeCode is the key every entity manager uses; on profiles the column
+ * behind it is profiletype.
+ *
+ * This is the path the three entity searchers used to walk through an if/else chain of their own,
+ * each responsible for rejecting the unknown key. Declaring the mapping as data is what let that
+ * check move into {@link AbstractSearcherDAO} for every searcher at once.
+ */
+ @Test
+ void anAliasedKey_resolvesToItsColumn() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+
+ dao.searchId(new EntitySearchFilter[]{
+ ordered(new EntitySearchFilter<>(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY, false),
+ FieldSearchFilter.ASC_ORDER)});
+
+ String query = SqlShape.normalize(this.capture.single());
+ assertTrue(query.contains("authuserprofiles.profiletype"), query);
+ assertFalse(query.contains("typeCode"), query);
+ }
+
+ /** An alias is the only way in: the column it hides is not itself a key. */
+ @Test
+ void theColumnBehindAnAlias_isNotAKeyOfItsOwn() {
+ ProbeProfileSearcherDAO dao = this.capture.wire(new ProbeProfileSearcherDAO(), DERBY);
+ EntitySearchFilter[] filters = {ordered(new EntitySearchFilter<>("profiletype", false),
+ FieldSearchFilter.ASC_ORDER)};
+
+ assertThrows(RuntimeException.class, () -> dao.searchId(filters));
+ assertTrue(this.capture.getQueries().isEmpty(),
+ () -> "a query was still handed to the driver: " + this.capture.getQueries());
+ }
+
+ // ---------------------------------------------------------------- fixtures
+
+ private static FieldSearchFilter sortOnly(String key) {
+ FieldSearchFilter filter = new FieldSearchFilter<>(key);
+ filter.setSortOnly(true);
+ filter.setOrder(FieldSearchFilter.ASC_ORDER);
+ return filter;
+ }
+
+ private static FieldSearchFilter descriptionLike() {
+ return new FieldSearchFilter<>("descr", "test", true);
+ }
+
+ private static FieldSearchFilter ordered(FieldSearchFilter filter, String order) {
+ filter.setOrder(order);
+ return filter;
+ }
+
+ private static EntitySearchFilter ordered(EntitySearchFilter filter, String order) {
+ filter.setOrder(order);
+ return filter;
+ }
+
+ private static EntitySearchFilter attributeLike() {
+ return new EntitySearchFilter<>("Nome", true, "abc", true);
+ }
+
+ private static EntitySearchFilter orderedMetadata() {
+ return ordered(new EntitySearchFilter<>(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY, false, "PFL", false),
+ FieldSearchFilter.ASC_ORDER);
+ }
+
+ /** Exposes the count entry point: no manager in the engine reaches it for profiles. */
+ private static class ProbeProfileSearcherDAO extends UserProfileSearcherDAO {
+
+ Integer count(EntitySearchFilter[] filters) {
+ return this.countId(filters);
+ }
+ }
+
+ /** A subclass that writes an opening marker of its own: the wrapper then opens twice, closes once. */
+ private static class UnbalancedGroupDAO extends GroupDAO {
+
+ @Override
+ protected StringBuffer createMasterCountQueryBlock() {
+ return new StringBuffer(COUNT_QUERY_PREFIX).append(super.createMasterCountQueryBlock());
+ }
+ }
+
+}
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/SqlShape.java b/engine/src/test/java/com/agiletec/aps/system/common/SqlShape.java
new file mode 100644
index 0000000000..fe9243e397
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/SqlShape.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package com.agiletec.aps.system.common;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Reads the parts of a generated query that carry the contract, so that a shape assertion does not
+ * also assert whitespace or formatting. The count markers are the DAO's own constants: a test that
+ * pins the composition has to move when they move.
+ */
+public final class SqlShape {
+
+ public static final String COUNT_PREFIX = AbstractSearcherDAO.COUNT_QUERY_PREFIX;
+ public static final String COUNT_SUFFIX = AbstractSearcherDAO.COUNT_QUERY_SUFFIX;
+
+ private static final String ORDER_BY = " ORDER BY ";
+ private static final String GROUP_BY = "GROUP BY ";
+ /** The paging block, in either of the two syntaxes {@link QueryLimitResolver} emits. */
+ private static final String[] PAGING = {" OFFSET ", " LIMIT "};
+ /** A trailing sort direction. Possessive so a run of spaces cannot backtrack. */
+ private static final Pattern SORT_DIRECTION = Pattern.compile("(?i)\\s++(ASC|DESC)$");
+
+ private SqlShape() {
+ // utility
+ }
+
+ /**
+ * @param sql Any generated query.
+ * @return The same query with every run of whitespace reduced to one space.
+ */
+ public static String normalize(String sql) {
+ return (null == sql) ? null : sql.replaceAll("\\s+", " ").trim();
+ }
+
+ public static int occurrences(String sql, String marker) {
+ return StringUtils.countMatches(normalize(sql), normalize(marker));
+ }
+
+ public static boolean isCountQuery(String sql) {
+ return normalize(sql).startsWith(normalize(COUNT_PREFIX));
+ }
+
+ /**
+ * @param countQuery A count query.
+ * @return The block the count counts: everything the wrapper encloses.
+ */
+ public static String countBody(String countQuery) {
+ String query = normalize(countQuery);
+ String prefix = normalize(COUNT_PREFIX);
+ String suffix = normalize(COUNT_SUFFIX);
+ if (!query.startsWith(prefix) || !query.endsWith(suffix)) {
+ throw new IllegalArgumentException("not a count query: " + query);
+ }
+ return query.substring(prefix.length(), query.length() - suffix.length()).trim();
+ }
+
+ /**
+ * @param listQuery A list query.
+ * @return The block the list query pages over: everything before ORDER BY and the paging block.
+ */
+ public static String listBody(String listQuery) {
+ String query = normalize(listQuery);
+ int cut = query.length();
+ for (String marker : new String[]{ORDER_BY, PAGING[0], PAGING[1]}) {
+ int index = query.indexOf(marker);
+ if (index >= 0 && index < cut) {
+ cut = index;
+ }
+ }
+ return query.substring(0, cut).trim();
+ }
+
+ /**
+ * @param query A count or a list query.
+ * @return The projected columns, in order, without the DISTINCT keyword.
+ */
+ public static List selectedColumns(String query) {
+ String body = isCountQuery(query) ? countBody(query) : normalize(query);
+ String selectList = StringUtils.substringBefore(StringUtils.substringAfter(body, "SELECT "), " FROM ");
+ selectList = StringUtils.removeStart(selectList.trim(), "DISTINCT ").trim();
+ List columns = new ArrayList<>();
+ for (String column : selectList.split(",")) {
+ columns.add(column.trim());
+ }
+ return Collections.unmodifiableList(columns);
+ }
+
+ public static boolean isDistinct(String query) {
+ String body = isCountQuery(query) ? countBody(query) : normalize(query);
+ return body.startsWith("SELECT DISTINCT ");
+ }
+
+ /**
+ * @param query A list query.
+ * @return The columns the ORDER BY references, without their direction.
+ */
+ public static List orderedColumns(String query) {
+ String normalized = normalize(query);
+ int index = normalized.indexOf(ORDER_BY);
+ if (index < 0) {
+ return Collections.emptyList();
+ }
+ String block = normalized.substring(index + ORDER_BY.length());
+ for (String marker : PAGING) {
+ block = StringUtils.substringBefore(block, marker);
+ }
+ List columns = new ArrayList<>();
+ for (String term : block.split(",")) {
+ columns.add(SORT_DIRECTION.matcher(term.trim()).replaceAll(""));
+ }
+ return Collections.unmodifiableList(columns);
+ }
+
+ /**
+ * @param query A count or a list query.
+ * @return The columns the body groups on, in order, or empty when the body does not group.
+ */
+ public static List groupedColumns(String query) {
+ String body = isCountQuery(query) ? countBody(query) : normalize(query);
+ int index = body.indexOf(GROUP_BY);
+ if (index < 0) {
+ return Collections.emptyList();
+ }
+ String block = body.substring(index + GROUP_BY.length());
+ block = StringUtils.substringBefore(block, ORDER_BY.trim());
+ List columns = new ArrayList<>();
+ for (String term : block.split(",")) {
+ columns.add(term.trim());
+ }
+ return Collections.unmodifiableList(columns);
+ }
+
+ public static boolean isGrouped(String query) {
+ return !groupedColumns(query).isEmpty();
+ }
+
+ /**
+ * @param term One term of an ORDER BY block.
+ * @return True when the term is an aggregate rather than a plain column reference.
+ */
+ public static boolean isAggregate(String term) {
+ return term.startsWith("MIN(") || term.startsWith("MAX(");
+ }
+
+ /**
+ * @param term An aggregate term.
+ * @return The column the aggregate is taken over.
+ */
+ public static String aggregatedColumn(String term) {
+ return StringUtils.substringBefore(StringUtils.substringAfter(term, "("), ")").trim();
+ }
+
+ /**
+ * @param query A list query.
+ * @return The paging block, or an empty string when the query is not paged.
+ */
+ public static String pagingBlock(String query) {
+ String normalized = normalize(query);
+ for (String marker : PAGING) {
+ int index = normalized.indexOf(marker);
+ if (index >= 0) {
+ return normalized.substring(index).trim();
+ }
+ }
+ return "";
+ }
+
+ /**
+ * @param query Any generated query.
+ * @return The joined tables, in the order they are joined.
+ */
+ public static List joinedTables(String query) {
+ List tables = new ArrayList<>();
+ String[] parts = normalize(query).split("(?i)INNER JOIN ");
+ for (int i = 1; i < parts.length; i++) {
+ tables.add(Arrays.stream(parts[i].split(" ")).findFirst().orElse(""));
+ }
+ return Collections.unmodifiableList(tables);
+ }
+
+}
diff --git a/engine/src/test/java/org/entando/entando/web/api/oauth2/ApiConsumerControllerIntegrationTest.java b/engine/src/test/java/org/entando/entando/web/api/oauth2/ApiConsumerControllerIntegrationTest.java
index 38b3847a5a..e4b6712277 100644
--- a/engine/src/test/java/org/entando/entando/web/api/oauth2/ApiConsumerControllerIntegrationTest.java
+++ b/engine/src/test/java/org/entando/entando/web/api/oauth2/ApiConsumerControllerIntegrationTest.java
@@ -257,6 +257,20 @@ private Date getDate(String date) {
return DateConverter.parseDate(date, SystemConstants.API_DATE_FORMAT);
}
+ /**
+ * issuedDate is an ApiConsumer field, so the REST validator accepts it as
+ * a sort key, and reMapFilterKeys forwards it unchanged - only key is
+ * remapped. The searcher's allowlist holds the column, issueddate, so it has to match
+ * the key without regard to case or this endpoint refuses a sort it advertises.
+ */
+ @Test
+ void shouldSortOnAFieldWhoseColumnDiffersOnlyByCase() throws Exception {
+ authRequest(get(BASE_URL).param("sort", "issuedDate"))
+ .andExpect(status().isOk());
+ authRequest(get(BASE_URL).param("sort", "expirationDate").param("direction", "DESC"))
+ .andExpect(status().isOk());
+ }
+
private ResultActions authRequest(MockHttpServletRequestBuilder requestBuilder) throws Exception {
return mockMvc.perform(requestBuilder
.header("Authorization", "Bearer " + accessToken)
diff --git a/engine/src/test/java/org/entando/entando/web/guifragment/GuiFragmentControllerIntegrationTest.java b/engine/src/test/java/org/entando/entando/web/guifragment/GuiFragmentControllerIntegrationTest.java
index c5f93ce8fe..52b51e1be7 100644
--- a/engine/src/test/java/org/entando/entando/web/guifragment/GuiFragmentControllerIntegrationTest.java
+++ b/engine/src/test/java/org/entando/entando/web/guifragment/GuiFragmentControllerIntegrationTest.java
@@ -82,6 +82,23 @@ void testGetFragments_1() throws Exception {
testCors("/fragments");
}
+ /**
+ * pluginCode is an inherited GuiFragmentDtoSmall field, so the REST
+ * validator accepts it as a sort key, and GuiFragmentService passes the filters to the
+ * searcher without remapping. The searcher's allowlist holds the column, plugincode,
+ * so it has to match the key without regard to case or this endpoint refuses a sort it advertises.
+ */
+ @Test
+ void shouldSortOnAFieldWhoseColumnDiffersOnlyByCase() throws Exception {
+ String accessToken = getAccessToken();
+ mockMvc.perform(get("/fragments").param("sort", "pluginCode")
+ .header("Authorization", "Bearer " + accessToken))
+ .andExpect(status().isOk());
+ mockMvc.perform(get("/fragments").param("sort", "pluginCode").param("direction", "DESC")
+ .header("Authorization", "Bearer " + accessToken))
+ .andExpect(status().isOk());
+ }
+
@Test
void testGetFragments_2() throws Exception {
String accessToken = getAccessToken();
diff --git a/pom.xml b/pom.xml
index 78b1a987e2..b1fd49323e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1629,6 +1629,94 @@
+
+
+ test-postgresql
+
+ entando
+ localhost
+ 55432
+ org.postgresql.Driver
+ jdbc:postgresql://${test.database.hostname}:${test.database.port}/${test.database.name}port
+ jdbc:postgresql://${test.database.hostname}:${test.database.port}/${test.database.name}serv
+
+
+
+ org.postgresql
+ postgresql
+ 42.7.8
+ test
+
+
+
+
+
+ test-mysql
+
+ entando
+ localhost
+ 53306
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://${test.database.hostname}:${test.database.port}/${test.database.name}port?useSSL=false&allowPublicKeyRetrieval=true
+ jdbc:mysql://${test.database.hostname}:${test.database.port}/${test.database.name}serv?useSSL=false&allowPublicKeyRetrieval=true
+
+
+
+ com.mysql
+ mysql-connector-j
+ 9.4.0
+ test
+
+
+
+
+ test-oracle
+
+ entando
+ localhost
+ 1521
+ TESTENV
+ oracle.jdbc.OracleDriver
+
+ jdbc:oracle:thin:@//${test.database.hostname}:${test.database.port}/${test.database.service}
+ jdbc:oracle:thin:@//${test.database.hostname}:${test.database.port}/${test.database.service}
+
+ -Djava.security.egd=file:/dev/./urandom -Doracle.jdbc.disableOob=true -Doracle.net.disableOob=true
+
+
+
+ com.oracle.ojdbc
+ ojdbc8
+ 19.3.0.0
+ test
+
+
+
local-dev
diff --git a/seo-plugin/src/main/java/org/entando/entando/plugins/jpseo/aps/system/services/mapping/SeoMappingDAO.java b/seo-plugin/src/main/java/org/entando/entando/plugins/jpseo/aps/system/services/mapping/SeoMappingDAO.java
index 0cf13c2edd..64c01fb5cc 100644
--- a/seo-plugin/src/main/java/org/entando/entando/plugins/jpseo/aps/system/services/mapping/SeoMappingDAO.java
+++ b/seo-plugin/src/main/java/org/entando/entando/plugins/jpseo/aps/system/services/mapping/SeoMappingDAO.java
@@ -36,6 +36,7 @@
import com.agiletec.aps.system.common.AbstractSearcherDAO;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.SearchableFields;
import org.entando.entando.ent.exception.EntException;
/**
@@ -44,6 +45,13 @@
public class SeoMappingDAO extends AbstractSearcherDAO implements ISeoMappingDAO {
private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(SeoMappingDAO.class);
+
+ /** The columns of jpseo_friendlycode a search key may name. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "friendlycode",
+ "pagecode",
+ "contentid",
+ "langcode");
private static final String TABLE_NAME = "jpseo_friendlycode";
@@ -169,8 +177,8 @@ public List searchFriendlyCode(FieldSearchFilter[] filters) {
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- return metadataFieldKey;
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
@Override
diff --git a/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageDAO.java b/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageDAO.java
index 9dadbf7601..5d70a2843d 100644
--- a/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageDAO.java
+++ b/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageDAO.java
@@ -67,6 +67,19 @@ protected String getAddEntityRecordQuery() {
return ADD_MESSAGE;
}
+ /**
+ * Drop the sub-second part of a date before it is written. Both columns are declared as a plain
+ * timestamp, so the fraction cannot be stored in any case, but the engines disagree on how they
+ * discard it: MySQL rounds to the nearest second while Derby, PostgreSQL and Oracle truncate. Left
+ * to the engine, a value written at .5 or later comes back a second ahead of the one held in memory.
+ *
+ * @param date The date to store.
+ * @return The same instant, floored to the second.
+ */
+ private static Timestamp toWholeSeconds(java.util.Date date) {
+ return new Timestamp(Math.floorDiv(date.getTime(), 1000L) * 1000L);
+ }
+
@Override
protected void buildAddEntityStatement(IApsEntity entity, PreparedStatement stat) throws Throwable {
Message message = (Message) entity;
@@ -74,7 +87,7 @@ protected void buildAddEntityStatement(IApsEntity entity, PreparedStatement stat
stat.setString(2, message.getUsername());
stat.setString(3, message.getLangCode());
stat.setString(4, message.getTypeCode());
- stat.setTimestamp(5, new Timestamp(message.getCreationDate().getTime()));
+ stat.setTimestamp(5, toWholeSeconds(message.getCreationDate()));
stat.setString(6, message.getXML());
}
@@ -135,7 +148,7 @@ public void addAnswer(Answer answer) throws EntException {
stat.setString(1, answer.getAnswerId());
stat.setString(2, answer.getMessageId());
stat.setString(3, answer.getOperator());
- stat.setTimestamp(4, new Timestamp(answer.getSendDate().getTime()));
+ stat.setTimestamp(4, toWholeSeconds(answer.getSendDate()));
stat.setString(5, answer.getText());
stat.executeUpdate();
conn.commit();
diff --git a/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageSearcherDAO.java b/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageSearcherDAO.java
index 6a01ca63b3..6325ec3eb9 100644
--- a/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageSearcherDAO.java
+++ b/webdynamicform-plugin/src/main/java/com/agiletec/plugins/jpwebdynamicform/aps/system/services/message/MessageSearcherDAO.java
@@ -21,6 +21,7 @@
*/
package com.agiletec.plugins.jpwebdynamicform.aps.system.services.message;
+import com.agiletec.aps.system.common.SearchableFields;
import com.agiletec.aps.system.common.entity.AbstractEntitySearcherDAO;
import com.agiletec.aps.system.common.entity.IEntityManager;
import com.agiletec.aps.system.common.entity.model.ApsEntityRecord;
@@ -40,12 +41,22 @@
*/
public class MessageSearcherDAO extends AbstractEntitySearcherDAO implements IMessageSearcherDAO {
+ private static final String MESSAGEID = "messageid";
+ private static final String MESSAGETYPE = "messagetype";
+
+ /** The search keys this searcher accepts. */
+ private static final SearchableFields SEARCHABLE_FIELDS = SearchableFields.columns(
+ "username")
+ .alias(IEntityManager.ENTITY_ID_FILTER_KEY, MESSAGEID)
+ .alias(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY, MESSAGETYPE)
+ .alias(IMessageManager.CREATION_DATE_FILTER_KEY, "creationdate");
+
@Override
protected ApsEntityRecord createRecord(ResultSet result) throws Throwable {
MessageRecordVO record = new MessageRecordVO();
- record.setId(result.getString("messageid"));
+ record.setId(result.getString(MESSAGEID));
record.setXml(result.getString("messagexml"));
- record.setTypeCode(result.getString("messagetype"));
+ record.setTypeCode(result.getString(MESSAGETYPE));
record.setUsername(result.getString("username"));
record.setLangCode(result.getString("langcode"));
record.setCreationDate(result.getTimestamp("creationdate"));
@@ -95,7 +106,8 @@ protected String createMessageQueryString(EntitySearchFilter[] filters, boolean
boolean hasAppendWhereClause = this.appendFullAttributeFilterQueryBlocks(filters, query, false);
hasAppendWhereClause = this.appendMetadataFieldFilterQueryBlocks(filters, query, hasAppendWhereClause);
this.appendAnsweredFilterQueryBlocks(answered, query, hasAppendWhereClause);
- appendOrderQueryBlocks(filters, query, false);
+ boolean grouped = this.appendGroupByQueryBlock(filters, query, selectAll);
+ appendOrderQueryBlocks(filters, query, false, grouped);
return query.toString();
}
@@ -120,12 +132,12 @@ protected String getEntityMasterTableName() {
@Override
protected String getEntityMasterTableIdFieldName() {
- return "messageid";
+ return MESSAGEID;
}
@Override
protected String getEntityMasterTableIdTypeFieldName() {
- return "messagetype";
+ return MESSAGETYPE;
}
@Override
@@ -135,7 +147,7 @@ protected String getEntitySearchTableName() {
@Override
protected String getEntitySearchTableIdFieldName() {
- return "messageid";
+ return MESSAGEID;
}
@Override
@@ -145,20 +157,12 @@ protected String getEntityAttributeRoleTableName() {
@Override
protected String getEntityAttributeRoleTableIdFieldName() {
- return "messageid";
+ return MESSAGEID;
}
@Override
- protected String getTableFieldName(String metadataFieldKey) {
- if (metadataFieldKey.equals(IEntityManager.ENTITY_ID_FILTER_KEY)) {
- return this.getEntityMasterTableIdFieldName();
- } else if (metadataFieldKey.equals(IEntityManager.ENTITY_TYPE_CODE_FILTER_KEY)) {
- return this.getEntityMasterTableIdTypeFieldName();
- } else if (metadataFieldKey.equals(IMessageManager.USERNAME_FILTER_KEY)) {
- return "username";
- } else if (metadataFieldKey.equals(IMessageManager.CREATION_DATE_FILTER_KEY)) {
- return "creationdate";
- } else throw new RuntimeException("Chiave di ricerca '" + metadataFieldKey + "' non riconosciuta");
+ protected SearchableFields getSearchableFields() {
+ return SEARCHABLE_FIELDS;
}
}