From c28afd0ba250b41105ea0a710e24a8a8f00b7690 Mon Sep 17 00:00:00 2001 From: Elia Mezzano Date: Fri, 31 Jul 2026 11:23:31 +0200 Subject: [PATCH 1/2] Fixed common search returning multiple values in case of join --- .../services/content/AbstractContentSearcherDAO.java | 4 +++- .../jacms/aps/system/services/resource/ResourceDAO.java | 2 ++ .../aps/system/services/content/ContentSearcherDAO.java | 4 +++- .../agiletec/aps/system/common/AbstractSearcherDAO.java | 9 ++++++++- .../system/common/entity/AbstractEntitySearcherDAO.java | 2 ++ 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java index 575e5c2b91..94c628fc97 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java @@ -220,7 +220,9 @@ protected String createQueryString(EntitySearchFilter[] filters, String[] groups if (!isCount) { boolean ordered = this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } + } else { + this.closeMasterCountQueryBlock(query); + } //System.out.println("********** " + query.toString()); return query.toString(); } diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java index 00e6d41760..4094dd1699 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java @@ -382,6 +382,8 @@ private String createQueryString(FieldSearchFilter[] filters, List categ if (!isCount) { super.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); + } else { + this.closeMasterCountQueryBlock(query); } return query.toString(); } diff --git a/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java b/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java index 077e2f3bab..ef0177585e 100644 --- a/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java +++ b/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java @@ -174,7 +174,9 @@ private String createQueryString(List workflowFilters, if (!isCount) { appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } + } else { + this.closeMasterCountQueryBlock(query); + } return query.toString(); } diff --git a/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java index 8a192e97b7..ec25051ac7 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java @@ -231,6 +231,8 @@ protected String createQueryString(FieldSearchFilter[] filters, boolean isCount, if (!isCount) { boolean ordered = appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); + } else { + this.closeMasterCountQueryBlock(query); } return query.toString(); } @@ -247,11 +249,16 @@ protected StringBuffer createBaseQueryBlock(FieldSearchFilter[] filters, boolean protected StringBuffer createMasterCountQueryBlock() { String masterTableName = this.getMasterTableName(); - StringBuffer query = new StringBuffer("SELECT COUNT(*)"); + StringBuffer query = new StringBuffer("SELECT COUNT(*) FROM ( SELECT DISTINCT "); + query.append(masterTableName).append(".").append(this.getMasterTableIdFieldName()); query.append(" FROM ").append(masterTableName).append(" "); return query; } + protected void closeMasterCountQueryBlock(StringBuffer query) { + query.append(") counter"); + } + private StringBuffer createMasterSelectQueryBlock(FieldSearchFilter[] filters, boolean selectAll) { String masterTableName = this.getMasterTableName(); StringBuffer query = new StringBuffer("SELECT ").append(masterTableName).append("."); 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..c698f299aa 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 @@ -219,6 +219,8 @@ protected String createQueryString(EntitySearchFilter[] filters, boolean isCount if (!isCount) { boolean ordered = this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); + } else { + this.closeMasterCountQueryBlock(query); } return query.toString(); } From 1ad07134e74b30b21203e363afb3e265d92e0bfc Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 28 Aug 2026 17:01:07 +0200 Subject: [PATCH 2/2] ESB-1223 Introduced regression tests to test the patch --- .gitignore | 3 + .../content/AbstractContentSearcherDAO.java | 9 +- .../content/PublicContentSearcherDAO.java | 2 +- .../system/services/resource/ResourceDAO.java | 29 ++- .../ContentSearchJoinCountRegressionTest.java | 192 ++++++++++++++++++ .../ContentControllerIntegrationTest.java | 36 ++-- .../services/content/ContentSearcherDAO.java | 8 +- .../system/common/AbstractSearcherDAO.java | 144 +++++++++++-- .../entity/AbstractEntitySearcherDAO.java | 121 ++++++++--- .../services/actionlog/ActionLogDAO.java | 2 +- .../java/com/agiletec/ConfigTestUtils.java | 18 ++ pom.xml | 88 ++++++++ .../system/services/message/MessageDAO.java | 17 +- 13 files changed, 592 insertions(+), 77 deletions(-) create mode 100644 cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentSearchJoinCountRegressionTest.java diff --git a/.gitignore b/.gitignore index a774f6d738..d8be0908a1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ target work *.tgz derby.log +/.REVIEW/ +/.TESTING/ +/.PLAN/ diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java index 94c628fc97..16deb1fe72 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/AbstractContentSearcherDAO.java @@ -156,7 +156,7 @@ protected PreparedStatement buildStatement(EntitySearchFilter[] filters, //System.out.println("QUERY : " + query); PreparedStatement stat = null; try { - stat = conn.prepareStatement(query); + stat = this.prepareStatement(conn, query); int index = 0; index = super.addAttributeFilterStatementBlock(filters, index, stat); index = this.addMetadataFieldFilterStatementBlock(filters, index, stat); @@ -218,13 +218,10 @@ protected String createQueryString(EntitySearchFilter[] filters, String[] groups this.addGroupsQueryBlock(query, groups); } if (!isCount) { - boolean ordered = this.appendOrderQueryBlocks(filters, query, false); + this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } else { - this.closeMasterCountQueryBlock(query); } - //System.out.println("********** " + query.toString()); - return query.toString(); + return this.toQueryString(query, isCount); } protected void addGroupsQueryBlock(StringBuffer query, Collection userGroupCodes) { diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/PublicContentSearcherDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/PublicContentSearcherDAO.java index 28d20951bc..aa79ae1dd5 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/PublicContentSearcherDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/PublicContentSearcherDAO.java @@ -85,7 +85,7 @@ protected PreparedStatement buildStatement(EntitySearchFilter[] filters, String query = this.createQueryString(filters, groupsArr, categories, orClauseCategoryFilter, groupsForSelect, isCount, selectAll); PreparedStatement stat = null; try { - stat = conn.prepareStatement(query); + stat = this.prepareStatement(conn, query); int index = 0; index = super.addAttributeFilterStatementBlock(filters, index, stat); index = this.addMetadataFieldFilterStatementBlock(filters, index, stat); diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java index 4094dd1699..2582e8cecc 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceDAO.java @@ -361,7 +361,7 @@ private PreparedStatement buildStatement(FieldSearchFilter[] filters, List 0) { for (String category : categories) { @@ -382,14 +382,14 @@ private String createQueryString(FieldSearchFilter[] filters, List categ if (!isCount) { super.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } else { - this.closeMasterCountQueryBlock(query); } - return query.toString(); + return this.toQueryString(query, isCount); } private StringBuffer createBaseQueryBlock(FieldSearchFilter[] filters, boolean selectAll, boolean isCount, List categories) { - StringBuffer query = super.createBaseQueryBlock(filters, isCount, selectAll); + // count and list share one body: the category joins are the only thing that can return several + // rows per resource, and both sides have to see the same set + StringBuffer query = this.createMasterSelectQueryBlock(filters, selectAll); if (categories != null) { for (int i = 0; i < categories.size(); i++) { query.append(String.format( @@ -563,6 +563,25 @@ public void updateResourceRelations(ResourceInterface resource) { } } + /** + * A resource holds one resourcerelations row per category, and nothing in the schema + * forbids the same pair twice, so the joined query can return the resource more than once. Both the + * list and the count select distinct ids; the columns the ORDER BY references have to be projected + * as well, since Derby and PostgreSQL reject an ORDER BY outside the select list under DISTINCT. + */ + @Override + protected StringBuffer createMasterSelectQueryBlock(FieldSearchFilter[] filters, boolean selectAll) { + if (selectAll) { + return super.createMasterSelectQueryBlock(filters, selectAll); + } + String masterTableName = this.getMasterTableName(); + StringBuffer query = new StringBuffer("SELECT DISTINCT ").append(masterTableName).append(".") + .append(this.getMasterTableIdFieldName()); + this.appendOrderFieldsSelectBlock(filters, query); + query.append(" FROM ").append(masterTableName).append(" "); + return query; + } + @Override protected String getMasterTableName() { return "resources"; diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentSearchJoinCountRegressionTest.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentSearchJoinCountRegressionTest.java new file mode 100644 index 0000000000..f035328ce2 --- /dev/null +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentSearchJoinCountRegressionTest.java @@ -0,0 +1,192 @@ +/* + * 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.plugins.jacms.aps.system.services.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.agiletec.aps.BaseTestCase; +import com.agiletec.aps.system.SystemConstants; +import com.agiletec.aps.system.common.FieldSearchFilter; +import com.agiletec.aps.system.common.entity.model.EntitySearchFilter; +import com.agiletec.aps.system.common.model.dao.SearcherDaoPaginatedResult; +import com.agiletec.aps.system.services.group.IGroupManager; +import com.agiletec.plugins.jacms.aps.system.JacmsSystemConstants; +import com.agiletec.plugins.jacms.aps.system.services.resource.IResourceManager; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for commit c28afd0ba ("Fixed common search returning multiple values in case of + * join"), which made the count query distinct while leaving the list query - and therefore SQL + * pagination - working on the multiplied row set. + * + *

The fixture attribute "Titolo" is stored in workcontentsearch once per language (it, en) for 11 + * contents, so an attribute filter on it joins 22 rows for 11 distinct contents.

+ */ +class ContentSearchJoinCountRegressionTest extends BaseTestCase { + + /** Contents holding the multi-language "Titolo" attribute, in creation-date order. */ + private static final String[] EXPECTED_CONTENTS = {"EVN191", "EVN192", "EVN193", "EVN194", "EVN103", + "EVN20", "EVN23", "EVN24", "EVN25", "EVN41", "EVN21"}; + + private static final String JOINING_ATTRIBUTE = "Titolo"; + + private IContentManager contentManager; + private IResourceManager resourceManager; + private IGroupManager groupManager; + + @BeforeEach + private void init() throws Exception { + this.contentManager = (IContentManager) this.getService(JacmsSystemConstants.CONTENT_MANAGER); + this.resourceManager = (IResourceManager) this.getService(JacmsSystemConstants.RESOURCE_MANAGER); + this.groupManager = (IGroupManager) this.getService(SystemConstants.GROUP_MANAGER); + } + + /** + * The behaviour c28afd0ba intends to deliver: the count must collapse the rows multiplied by the + * join on workcontentsearch. Green after the commit, red before it. + */ + @Test + void countContents_withJoiningAttributeFilter_countsDistinctContents() throws Throwable { + EntitySearchFilter[] filters = {creationDateOrder(), joiningAttributeFilter()}; + List allMatching = this.contentManager.loadWorkContentsId(null, false, filters, allGroups()); + assertEquals(EXPECTED_CONTENTS.length, allMatching.size()); + + Integer count = this.contentManager.countWorkContents(null, false, filters, allGroups()); + assertEquals(EXPECTED_CONTENTS.length, count.intValue()); + } + + /** + * The regression. The count is distinct but the list query is not, and LIMIT/OFFSET is applied to + * the multiplied row set, so a caller that derives the number of pages from the count - as + * PagedMetadata and the admin content finder both do - stops paging before it has seen every + * content. + */ + @Test + void paginatedSearch_withJoiningAttributeFilter_returnsEveryCountedContent() throws Throwable { + int pageSize = EXPECTED_CONTENTS.length; + int declaredCount = paginatedWorkContents(pageSize, 0).getCount(); + int lastPage = lastPage(declaredCount, pageSize); + + // page exactly the way PagedMetadata and the admin content finder do: the declared count is + // the only thing that tells the caller when to stop asking for pages. + Set reachable = new HashSet<>(); + for (int page = 0; page < lastPage; page++) { + reachable.addAll(paginatedWorkContents(pageSize, page * pageSize).getList()); + } + + List unreachable = Arrays.stream(EXPECTED_CONTENTS) + .filter(id -> !reachable.contains(id)) + .collect(Collectors.toList()); + assertTrue(unreachable.isEmpty(), "contents matching the filter but not reachable within the " + + lastPage + " page(s) implied by the declared count of " + declaredCount + ": " + unreachable); + } + + /** + * Same root cause, pre-existing rather than introduced by c28afd0ba: LIMIT slices duplicated rows + * and de-duplication happens per page in Java, so a content whose rows straddle the page boundary + * is served twice. + */ + @Test + void paginatedSearch_withJoiningAttributeFilter_neverRepeatsAnIdAcrossPages() throws Throwable { + int pageSize = EXPECTED_CONTENTS.length; + List seen = new ArrayList<>(); + List repeated = new ArrayList<>(); + // the join yields two rows per content, so walk the whole multiplied row set + for (int offset = 0; offset < EXPECTED_CONTENTS.length * 2; offset += pageSize) { + for (String id : paginatedWorkContents(pageSize, offset).getList()) { + if (seen.contains(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + } + assertTrue(repeated.isEmpty(), "ids served on more than one page: " + repeated); + } + + /** + * Guard: a metadata-only filter builds no join, so the count keeps the value it had before + * c28afd0ba. This is the "unrelated searchers are unaffected" case, expressed on the searcher the + * commit was aimed at. + */ + @Test + void countContents_withoutJoiningFilter_isUnchanged() throws Throwable { + EntitySearchFilter descr = new EntitySearchFilter<>(IContentManager.CONTENT_DESCR_FILTER_KEY, + false, "Cont", true); + EntitySearchFilter[] filters = {creationDateOrder(), descr}; + + List ids = this.contentManager.loadWorkContentsId(null, false, filters, allGroups()); + Integer count = this.contentManager.countWorkContents(null, false, filters, allGroups()); + assertEquals(9, count.intValue()); + assertEquals(ids.size(), count.intValue()); + } + + /** + * Guard: resourcerelations rows are written from a Set, so the category joins are 1:1 and the + * distinct count cannot change the value resources report - with one category and with two. + */ + @Test + void countResources_matchesResourceListSize() throws Throwable { + assertResourceCountMatchesList(List.of("resCat1")); + assertResourceCountMatchesList(List.of("resCat1", "resCat3")); + } + + private void assertResourceCountMatchesList(List categories) throws Throwable { + SearcherDaoPaginatedResult result = this.resourceManager + .getPaginatedResourcesId(new FieldSearchFilter[0], categories, allGroups()); + assertFalse(result.getList().isEmpty(), "empty fixture for categories " + categories); + assertEquals(result.getList().size(), result.getCount().intValue(), + "count and list disagree for categories " + categories); + } + + private SearcherDaoPaginatedResult paginatedWorkContents(int pageSize, int offset) throws Throwable { + EntitySearchFilter[] filters = {creationDateOrder(), joiningAttributeFilter(), + new EntitySearchFilter<>(pageSize, offset)}; + return this.contentManager.getPaginatedWorkContentsId(null, false, filters, allGroups()); + } + + /** + * Attribute filter with no value and no language code: it matches every workcontentsearch row for + * the attribute, in every language, which is what multiplies the joined rows. + */ + private EntitySearchFilter joiningAttributeFilter() { + return new EntitySearchFilter<>(JOINING_ATTRIBUTE, true); + } + + private EntitySearchFilter creationDateOrder() { + EntitySearchFilter order = new EntitySearchFilter<>( + IContentManager.CONTENT_CREATION_DATE_FILTER_KEY, false); + order.setOrder(EntitySearchFilter.ASC_ORDER); + return order; + } + + private static int lastPage(int count, int pageSize) { + return (int) Math.ceil((double) count / (double) pageSize); + } + + private List allGroups() { + return this.groupManager.getGroups().stream().map(group -> group.getName()) + .collect(Collectors.toList()); + } + +} diff --git a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java index e4400fdb98..427c97d09d 100644 --- a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java +++ b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java @@ -3082,15 +3082,17 @@ void testLoadOrderedPublicEvents_8() throws Throwable { result.andDo(resultPrint()) .andExpect(status().isOk()) .andExpect(jsonPath("$.payload.size()", is(9))) - .andExpect(jsonPath("$.payload[0].id", is("EVN20"))) + // every row in this result has typecode EVN, so the sort key is fully tied and the + // order comes from the contentid tie-breaker + .andExpect(jsonPath("$.payload[0].id", is("EVN191"))) .andExpect(jsonPath("$.payload[1].id", is("EVN192"))) - .andExpect(jsonPath("$.payload[2].id", is("EVN23"))) - .andExpect(jsonPath("$.payload[3].id", is("EVN24"))) - .andExpect(jsonPath("$.payload[4].id", is("EVN21"))) - .andExpect(jsonPath("$.payload[5].id", is("EVN25"))) - .andExpect(jsonPath("$.payload[6].id", is("EVN191"))) - .andExpect(jsonPath("$.payload[7].id", is("EVN194"))) - .andExpect(jsonPath("$.payload[8].id", is("EVN193"))); + .andExpect(jsonPath("$.payload[2].id", is("EVN193"))) + .andExpect(jsonPath("$.payload[3].id", is("EVN194"))) + .andExpect(jsonPath("$.payload[4].id", is("EVN20"))) + .andExpect(jsonPath("$.payload[5].id", is("EVN21"))) + .andExpect(jsonPath("$.payload[6].id", is("EVN23"))) + .andExpect(jsonPath("$.payload[7].id", is("EVN24"))) + .andExpect(jsonPath("$.payload[8].id", is("EVN25"))); } @Test @@ -3110,15 +3112,17 @@ void testLoadOrderedPublicEvents_9() throws Throwable { result.andDo(resultPrint()) .andExpect(status().isOk()) .andExpect(jsonPath("$.payload.size()", is(9))) - .andExpect(jsonPath("$.payload[0].id", is("EVN193"))) - .andExpect(jsonPath("$.payload[1].id", is("EVN194"))) - .andExpect(jsonPath("$.payload[2].id", is("EVN191"))) - .andExpect(jsonPath("$.payload[3].id", is("EVN25"))) - .andExpect(jsonPath("$.payload[4].id", is("EVN21"))) - .andExpect(jsonPath("$.payload[5].id", is("EVN24"))) + // every row in this result has the same status, so the sort key is fully tied and the + // order comes from the contentid tie-breaker + .andExpect(jsonPath("$.payload[0].id", is("EVN191"))) + .andExpect(jsonPath("$.payload[1].id", is("EVN192"))) + .andExpect(jsonPath("$.payload[2].id", is("EVN193"))) + .andExpect(jsonPath("$.payload[3].id", is("EVN194"))) + .andExpect(jsonPath("$.payload[4].id", is("EVN20"))) + .andExpect(jsonPath("$.payload[5].id", is("EVN21"))) .andExpect(jsonPath("$.payload[6].id", is("EVN23"))) - .andExpect(jsonPath("$.payload[7].id", is("EVN192"))) - .andExpect(jsonPath("$.payload[8].id", is("EVN20"))); + .andExpect(jsonPath("$.payload[7].id", is("EVN24"))) + .andExpect(jsonPath("$.payload[8].id", is("EVN25"))); } @Test diff --git a/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java b/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java index ef0177585e..52ad81f6b1 100644 --- a/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java +++ b/contentworkflow-plugin/src/main/java/com/agiletec/plugins/jpcontentworkflow/aps/system/services/content/ContentSearcherDAO.java @@ -106,7 +106,7 @@ private PreparedStatement buildStatement(List workflowFilt String query = this.createQueryString(workflowFilters, filters, categories, orClauseCategoryFilter, groupsForSelect, isCount, selectAll); PreparedStatement stat = null; try { - stat = conn.prepareStatement(query); + stat = this.prepareStatement(conn, query); int index = 0; index = super.addAttributeFilterStatementBlock(filters, index, stat); index = this.addMetadataFieldFilterStatementBlock(filters, index, stat); @@ -172,12 +172,10 @@ private String createQueryString(List workflowFilters, } query.append(") "); if (!isCount) { - appendOrderQueryBlocks(filters, query, false); + this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } else { - this.closeMasterCountQueryBlock(query); } - return query.toString(); + return this.toQueryString(query, isCount); } } diff --git a/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java index ec25051ac7..a187e71919 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/AbstractSearcherDAO.java @@ -23,10 +23,13 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.agiletec.aps.util.ApsTenantApplicationUtils; import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; @@ -43,6 +46,15 @@ public abstract class AbstractSearcherDAO extends AbstractDAO { private static final EntLogger logger = EntLogFactory.getSanitizedLogger(AbstractSearcherDAO.class); private static final String DEFAULT_LIKE_CLAUSE = "LIKE ? "; + /** + * The two halves wrapping the body of a count query, so that the count always matches the number of + * rows the corresponding list query can return. Applied together by {@link #toQueryString}, which is + * the only place allowed to use them: a count block that cannot be opened on its own cannot be left + * open either. + */ + protected static final String COUNT_QUERY_PREFIX = "SELECT COUNT(*) FROM ( "; + protected static final String COUNT_QUERY_SUFFIX = ") counter"; + private String likeClause; private String dataSourceClassName; @@ -97,10 +109,9 @@ protected FieldSearchFilter[] addFilter(FieldSearchFilter[] filters, FieldSearch protected PreparedStatement buildStatement(FieldSearchFilter[] filters, boolean isCount, boolean selectAll, Connection conn) { String query = this.createQueryString(filters, isCount, selectAll); - logger.trace("{}", query); PreparedStatement stat = null; try { - stat = conn.prepareStatement(query); + stat = this.prepareStatement(conn, query); int index = 0; index = this.addMetadataFieldFilterStatementBlock(filters, index, stat); } catch (Throwable t) { @@ -110,6 +121,37 @@ protected PreparedStatement buildStatement(FieldSearchFilter[] filters, boolean return stat; } + /** + * The single point where a searcher hands a query to the driver. Every buildStatement + * goes through it, so the balance check below runs without anyone having to remember it. + * + * @param conn The connection. + * @param query The query to prepare. + * @return The prepared statement. + * @throws SQLException In case of error. + */ + protected final PreparedStatement prepareStatement(Connection conn, String query) throws SQLException { + logger.trace("{}", query); + this.checkCountQueryBlockBalance(query); + return conn.prepareStatement(query); + } + + /** + * Report a query whose count block is left open, or closed twice. {@link #toQueryString} cannot build + * one, but a subclass writing the markers by hand can, and the database would reject it with an opaque + * syntax error naming no source. The query is left untouched: this names the DAO that built it. + * + * @param query The query about to be prepared. + */ + private void checkCountQueryBlockBalance(String query) { + int opened = StringUtils.countMatches(query, COUNT_QUERY_PREFIX); + int closed = StringUtils.countMatches(query, COUNT_QUERY_SUFFIX); + if (opened != closed) { + logger.error("Unbalanced count query block built by {}: {} opening and {} closing markers - query: {}", + this.getClass().getName(), opened, closed, query); + } + } + /** * Add to the statement the filters on the entity metadata. * @@ -227,14 +269,28 @@ protected void addObjectSearchStatementBlock(PreparedStatement stat, protected String createQueryString(FieldSearchFilter[] filters, boolean isCount, boolean selectAll) { StringBuffer query = this.createBaseQueryBlock(filters, isCount, selectAll); - boolean hasAppendWhereClause = this.appendMetadataFieldFilterQueryBlocks(filters, query, false); + this.appendMetadataFieldFilterQueryBlocks(filters, query, false); if (!isCount) { - boolean ordered = appendOrderQueryBlocks(filters, query, false); + this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } else { - this.closeMasterCountQueryBlock(query); } - return query.toString(); + return this.toQueryString(query, isCount); + } + + /** + * Close a query built by any of the createQueryString variants. A count wraps the body + * the matching list query pages over - both halves of the wrapper are applied here, in one + * expression, so the two can neither disagree nor be left unbalanced. + * + * @param query The body of the query: select block, joins and where clauses, without order or limit. + * @param isCount True when the query counts the rows of that body. + * @return The query to prepare. + */ + protected final String toQueryString(StringBuffer query, boolean isCount) { + if (!isCount) { + return query.toString(); + } + return new StringBuffer(COUNT_QUERY_PREFIX).append(query).append(COUNT_QUERY_SUFFIX).toString(); } protected StringBuffer createBaseQueryBlock(FieldSearchFilter[] filters, boolean isCount, boolean selectAll) { @@ -247,19 +303,24 @@ protected StringBuffer createBaseQueryBlock(FieldSearchFilter[] filters, boolean return query; } + /** + * The body counted by the count query of a searcher that queries the master table alone. No join can + * multiply a row here, so it is not distinct - a derived table without DISTINCT, aggregate or LIMIT is + * merged by the planner, leaving a plain count. Subclasses that do join must de-duplicate instead, and + * must do it on their list query too, or the two stop agreeing. Returns the body alone: + * {@link #toQueryString} wraps it. + * + * @return The body of the count query. + */ protected StringBuffer createMasterCountQueryBlock() { String masterTableName = this.getMasterTableName(); - StringBuffer query = new StringBuffer("SELECT COUNT(*) FROM ( SELECT DISTINCT "); + StringBuffer query = new StringBuffer("SELECT "); query.append(masterTableName).append(".").append(this.getMasterTableIdFieldName()); query.append(" FROM ").append(masterTableName).append(" "); return query; } - protected void closeMasterCountQueryBlock(StringBuffer query) { - query.append(") counter"); - } - - private StringBuffer createMasterSelectQueryBlock(FieldSearchFilter[] filters, boolean selectAll) { + protected StringBuffer createMasterSelectQueryBlock(FieldSearchFilter[] filters, boolean selectAll) { String masterTableName = this.getMasterTableName(); StringBuffer query = new StringBuffer("SELECT ").append(masterTableName).append("."); if (selectAll) { @@ -368,6 +429,8 @@ protected boolean appendOrderQueryBlocks(FieldSearchFilter[] filters, StringBuff if (filters == null) { return ordered; } + Set orderedFields = new HashSet<>(); + Object lastOrder = null; for (FieldSearchFilter filter : filters) { if (null != filter.getKey() && null != filter.getOrder() && !filter.isNullOption()) { if (!ordered) { @@ -378,11 +441,66 @@ protected boolean appendOrderQueryBlocks(FieldSearchFilter[] filters, StringBuff } String fieldName = this.getTableFieldName(filter.getKey()); query.append(this.getMasterTableName()).append(".").append(fieldName).append(" ").append(filter.getOrder()); + orderedFields.add(fieldName); + lastOrder = filter.getOrder(); } } + this.appendOrderTieBreaker(query, ordered, orderedFields, this.getMasterTableIdFieldName(), lastOrder); return ordered; } + /** + * Break ties on the master id so the ordering is total. A sort whose key repeats leaves the order of + * the tied rows to the database, and LIMIT/OFFSET then slices an order that can differ between the + * request for one page and the request for the next - so a row can be served twice, or never. + * + *

The tie-breaker follows the direction of the sort it breaks. When the requested key is the same + * for every row it becomes the only visible ordering, and a DESC request has to keep reading as + * descending.

+ * + * @param query The query under construction. + * @param ordered Whether an ORDER BY block was opened. + * @param orderedFields The master-table fields already ordered on. + * @param idFieldName The master id field. + * @param lastOrder The direction of the last order term, or null to default to ascending. + */ + protected void appendOrderTieBreaker(StringBuffer query, boolean ordered, Set orderedFields, + String idFieldName, Object lastOrder) { + if (!ordered || orderedFields.contains(idFieldName)) { + return; + } + String direction = (null == lastOrder) ? FieldSearchFilter.ASC_ORDER : lastOrder.toString(); + query.append(", ").append(this.getMasterTableName()).append(".").append(idFieldName) + .append(" ").append(direction); + } + + /** + * Project the columns the ORDER BY block will reference. Only needed by a subclass whose select block + * is distinct: Derby and PostgreSQL reject an ORDER BY on a column outside the select list under + * DISTINCT. Mirrors the filter predicate of {@link #appendOrderQueryBlocks} and must keep mirroring it. + * + * @param filters The filters of the query. + * @param query The query under construction. + */ + protected void appendOrderFieldsSelectBlock(FieldSearchFilter[] filters, StringBuffer query) { + if (null == filters) { + return; + } + Set projected = new HashSet<>(); + projected.add(this.getMasterTableIdFieldName()); + for (FieldSearchFilter filter : filters) { + if (null == filter.getKey() || null == filter.getOrder() || filter.isNullOption()) { + continue; + } + String fieldName = this.getTableFieldName(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 "); 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 c698f299aa..2e01c3a1e9 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,7 +20,9 @@ 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.entity.model.ApsEntityRecord; @@ -138,7 +140,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); @@ -217,12 +219,10 @@ protected String createQueryString(EntitySearchFilter[] filters, boolean isCount boolean hasAppendWhereClause = this.appendFullAttributeFilterQueryBlocks(filters, query, false); this.appendMetadataFieldFilterQueryBlocks(filters, query, hasAppendWhereClause); if (!isCount) { - boolean ordered = this.appendOrderQueryBlocks(filters, query, false); + this.appendOrderQueryBlocks(filters, query, false); this.appendLimitQueryBlock(filters, query); - } else { - this.closeMasterCountQueryBlock(query); } - return query.toString(); + return this.toQueryString(query, isCount); } /** @@ -238,7 +238,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); } @@ -248,33 +251,81 @@ 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("."); + StringBuffer query = new StringBuffer("SELECT "); + if (!selectAll) { + 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); } } query.append(" FROM ").append(masterTableName).append(" "); return query; } + 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.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(" "); + } + } + } + + /** + * 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. + */ + private void appendOrderFieldsSelectBlock(EntitySearchFilter[] filters, StringBuffer query) { + // 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 + Set projected = new HashSet<>(); + projected.add(this.getEntityMasterTableIdFieldName()); + for (int i = 0; i < filters.length; i++) { + EntitySearchFilter filter = filters[i]; + if ((null == filter.getKey() && null == filter.getRoleName()) + || null == filter.getOrder() || filter.isNullOption()) { + continue; + } + if (filter.isAttributeFilter()) { + 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); + } + } else { + String fieldName = this.getTableFieldName(filter.getKey()); + if (projected.add(fieldName)) { + query.append(", ").append(this.getEntityMasterTableName()).append(".").append(fieldName); + } + } + } + } + protected void appendJoinSearchTableQueryBlock(EntitySearchFilter[] filters, StringBuffer query) { if (filters == null) { return; @@ -419,6 +470,8 @@ protected boolean appendOrderQueryBlocks(EntitySearchFilter[] filters, StringBuf 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()) { @@ -434,9 +487,12 @@ protected boolean appendOrderQueryBlocks(EntitySearchFilter[] filters, StringBuf } else { String fieldName = this.getTableFieldName(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; } @@ -476,13 +532,7 @@ private void addAttributeOrderQueryBlock(String searchTableNameAlias, StringBuff if (order == null) { order = ""; } - Object object = filter.getValue(); - if (object == null) { - object = filter.getStart(); - } - if (object == null) { - object = filter.getEnd(); - } + Object object = this.getOrderReferenceValue(filter); if (null == object) { query.append(searchTableNameAlias).append(".textvalue ").append(order).append(", ") .append(searchTableNameAlias).append(".datevalue ").append(order).append(", ") @@ -493,6 +543,21 @@ private void addAttributeOrderQueryBlock(String searchTableNameAlias, StringBuff query.append(order); } + /** + * 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(); + } + if (object == null) { + object = filter.getEnd(); + } + return object; + } + private String getAttributeFieldColunm(EntitySearchFilter filter) { Object object = null; if (null != filter.getAllowedValues() && filter.getAllowedValues().size() > 0) { 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..e87487ccab 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 @@ -241,7 +241,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); diff --git a/engine/src/test/java/com/agiletec/ConfigTestUtils.java b/engine/src/test/java/com/agiletec/ConfigTestUtils.java index c5584df7f6..b83cfab250 100644 --- a/engine/src/test/java/com/agiletec/ConfigTestUtils.java +++ b/engine/src/test/java/com/agiletec/ConfigTestUtils.java @@ -164,6 +164,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) { @@ -171,6 +172,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/pom.xml b/pom.xml index 009335364b..4a4cd262e0 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/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();