From ab1e38507571806d375a34b7594684b6a9a19b58 Mon Sep 17 00:00:00 2001
From: "Matteo E. Minnai"
Date: Thu, 23 Jul 2026 12:43:12 +0200
Subject: [PATCH 01/16] ESB-1133 Introduced SOLR indexing with related UI
updates
---
README.md | 48 ++-
...stractBaseEntityAttributeConfigAction.java | 19 ++
...attribute-type-entry-composite-element.jsp | 13 +
.../CompositeAttributeConfigActionTest.java | 24 ++
.../port/00000000000001_dataPort_test.xml | 84 +++++
.../jacms/port/clob/test/contents_50.xml | 26 ++
.../jacms/port/clob/test/contents_51.xml | 26 ++
.../jacms/port/clob/test/contents_52.xml | 26 ++
.../jacms/port/clob/test/contents_53.xml | 22 ++
.../jacms/port/clob/test/sysconfig_1.xml | 17 +
.../apsadmin/jsp/content/contentFinding.jsp | 4 +-
.../services/content/TestContentManager.java | 146 ++++++++-
.../entity/TestContentEntityManager.java | 2 +-
.../content/TestContentFinderAction.java | 4 +-
.../attribute/TestContentLinkAction.java | 8 +-
.../TestHypertextAttributeAction.java | 8 +-
.../viewer/TestContentFinderViewerAction.java | 8 +-
.../entity/TestJacmsEntityManagersAction.java | 8 +-
.../ContentControllerIntegrationTest.java | 73 ++++-
.../content/TestContentFinderAction.java | 4 +-
.../content/TestIntroNewContentAction.java | 2 +-
.../common/entity/AbstractEntityDAO.java | 88 +++--
.../entity/NestedBooleanSearchSupport.java | 97 ++++++
.../entity/model/EntitySearchFilter.java | 6 +
.../model/attribute/CompositeAttribute.java | 8 +-
.../AbstractEntityDAONestedBooleanTest.java | 251 +++++++++++++++
.../NestedBooleanSearchSupportTest.java | 145 +++++++++
.../EntitySearchFilterNestedBooleanTest.java | 114 +++++++
.../content/AdvContentFacetManager.java | 41 +++
.../jpsolr/aps/system/solr/IndexerDAO.java | 38 ++-
.../jpsolr/aps/system/solr/SearcherDAO.java | 5 +-
.../system/solr/SolrComplexAttributes.java | 87 +++++
.../aps/system/solr/SolrFieldsChecker.java | 77 ++++-
.../system/solr/SolrSearchEngineManager.java | 54 +++-
.../solr/model/ContentTypeSettings.java | 24 +-
.../content/AdvContentFacetManagerTest.java | 72 +++++
.../aps/system/solr/IndexerDAOTest.java | 302 ++++++++++++++++++
.../aps/system/solr/SearcherDAOTest.java | 132 ++++++++
.../system/solr/SolrFieldsCheckerTest.java | 199 +++++++++++-
.../solr/model/ContentTypeSettingsTest.java | 207 ++++++++++++
.../AdvContentSearchControllerTest.java | 4 +-
41 files changed, 2411 insertions(+), 112 deletions(-)
create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml
create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml
create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml
create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml
create mode 100644 engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java
create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java
create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java
create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java
create mode 100644 solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java
create mode 100644 solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java
diff --git a/README.md b/README.md
index e7113c6878..34f719b7dd 100644
--- a/README.md
+++ b/README.md
@@ -32,8 +32,52 @@ To execute a specific test:
mvn clean test -Ppre-deployment-verification -pl -Dtest=
```
-By default the logging output in tests is minimized.
-The general log level is controlled by the variable `ROOT_LOG_LEVEL`, that in tests is set to `WARN` by default.
+By default the logging output in tests is minimized. See [Logging](#logging) below for how to get
+verbose/`DEBUG` output, both for the running webapp and for test runs (they work differently).
+
+## Logging
+
+Logging is configured via `engine/src/main/resources/base.xml` (logback) and driven by two environment
+variables:
+- `ROOT_LOG_LEVEL` — the root logger level. Defaults to `DEBUG` when running the webapp; overridden to
+ `WARN` when running tests (see `pom.xml` surefire configuration).
+- `LOG_LEVEL` — the console (`STDOUT`) appender threshold. Defaults to `WARN`, regardless of
+ `ROOT_LOG_LEVEL`.
+
+To run the webapp locally with `DEBUG` logs printed to the console:
+
+```
+cd webapp/
+LOG_LEVEL=DEBUG mvn package jetty:run-war -Pjetty-local -Dspring.profiles.active=swagger -DskipTests -DskipLicenseDownload -Pderby -Pkeycloak
+```
+
+`ROOT_LOG_LEVEL` does not need to be set for this, since it already defaults to `DEBUG` outside of tests;
+`LOG_LEVEL` is the variable that actually gates what reaches the console.
+
+Test runs are different: `entando-engine`'s test-jar ships `logback-test.xml`, which every other module
+picks up on its test classpath. It hardcodes `` (with explicit per-package `DEBUG`
+overrides only for a couple of Spring test loggers), so **`ROOT_LOG_LEVEL`/`LOG_LEVEL` have no effect on
+test runs** — only on the running webapp. To get `DEBUG` output from a test run, point Logback at a
+throwaway config instead:
+
+```
+cat > /tmp/logback-debug.xml <<'EOF'
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+EOF
+mvn clean test -Ppre-deployment-verification -pl -Dtest= \
+ -DargLine=-Dlogback.configurationFile=/tmp/logback-debug.xml
+```
+
+(Alternatively, add a one-off `` line to
+`engine/src/test/resources/logback-test.xml` before running the test — cheaper for a quick, throwaway
+check, but remember to revert it since it's a shared test resource.)
## Environment Variables List
| Group | Name | Value [default] | Description |
diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java
index 2f6c5560bd..33f3fcf79f 100644
--- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java
+++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java
@@ -14,6 +14,7 @@
package com.agiletec.apsadmin.system.entity.type;
import com.agiletec.aps.system.common.entity.IEntityManager;
+import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport;
import com.agiletec.aps.system.common.entity.model.IApsEntity;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeRole;
@@ -266,6 +267,24 @@ public boolean isSearchableOptionSupported(String attributeTypeCode) {
}
return false;
}
+
+ /**
+ * Whether the given attribute type may be flagged searchable when used as a composite child.
+ * Only plain boolean children are indexed (under the path key "<composite>_<boolean>") in
+ * the DB search tables; every other type - including CheckBox and ThreeState - is forced
+ * non-searchable as a composite child, so the searchable option must not be offered for them.
+ * @param attributeTypeCode the attribute type code.
+ * @return true only for the plain boolean type.
+ */
+ public boolean isNestedSearchableOptionSupported(String attributeTypeCode) {
+ try {
+ AttributeInterface attribute = this.getAttributePrototype(attributeTypeCode);
+ return NestedBooleanSearchSupport.isIndexableNestedBoolean(attribute);
+ } catch (Throwable t) {
+ _logger.error("error in isNestedSearchableOptionSupported", t);
+ }
+ return false;
+ }
public AttributeInterface getAttributePrototype(String typeCode) {
IEntityManager entityManager = this.getEntityManager();
diff --git a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp
index cd92c28cd6..cfb0a7f8da 100644
--- a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp
+++ b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp
@@ -135,6 +135,19 @@
+
+
+ test="#attribute.type == 'Boolean' || #attribute.type == 'ThreeState' || #attribute.type == 'CheckBox'">
_booleanFieldName contentIds = this._contentManager.searchId(null);
assertNotNull(contentIds);
- assertEquals(25, contentIds.size());
+ assertEquals(29, contentIds.size());
EntitySearchFilter creationOrder = new EntitySearchFilter(IContentManager.CONTENT_CREATION_DATE_FILTER_KEY, false);
creationOrder.setOrder(EntitySearchFilter.ASC_ORDER);
@@ -163,14 +163,14 @@ void testSearchContents_1_2() throws Throwable {
EntitySearchFilter[] filters4 = {versionFilter};
contentIds = this._contentManager.searchId(filters4);
assertNotNull(contentIds);
- assertEquals(22, contentIds.size());
+ assertEquals(26, contentIds.size());
}
@Test
void testSearchContents_1_4() throws Throwable {
List contentIds = this._contentManager.searchId(null);
assertNotNull(contentIds);
- assertEquals(25, contentIds.size());
+ assertEquals(29, contentIds.size());
EntitySearchFilter creationOrder = new EntitySearchFilter(IContentManager.CONTENT_CREATION_DATE_FILTER_KEY, false);
creationOrder.setOrder(EntitySearchFilter.ASC_ORDER);
@@ -198,7 +198,7 @@ void testSearchContents_1_5() throws Throwable {
EntitySearchFilter[] filters1 = {creationOrder, descrFilter};
List contentIds = this._contentManager.searchId(filters1);
assertNotNull(contentIds);
- String[] expected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"};
+ String[] expected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"};
assertEquals(expected1.length, contentIds.size());
this.verifyOrder(contentIds, expected1);
@@ -206,7 +206,7 @@ void testSearchContents_1_5() throws Throwable {
EntitySearchFilter[] filters2 = {creationOrder, descrFilter};
contentIds = this._contentManager.searchId(filters2);
assertNotNull(contentIds);
- String[] expected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"};
+ String[] expected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"};
assertEquals(expected2.length, contentIds.size());
this.verifyOrder(contentIds, expected2);
@@ -236,7 +236,7 @@ void testSearchPaginatedContents_1_5() throws Throwable {
EntitySearchFilter[] filters1 = {creationOrder, descrFilter, paginationFilter};
SearcherDaoPaginatedResult paginatedContentsId1 = this._contentManager.getPaginatedWorkContentsId(null, true, filters1, groupCodes);
assertNotNull(paginatedContentsId1);
- String[] totalExpected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"};
+ String[] totalExpected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"};
assertEquals(totalExpected1.length, paginatedContentsId1.getCount().intValue());
assertEquals(4, paginatedContentsId1.getList().size());
for (int i = 0; i < 4; i++) {
@@ -248,7 +248,7 @@ void testSearchPaginatedContents_1_5() throws Throwable {
EntitySearchFilter[] filters2 = {creationOrder, descrFilter, paginationFilter2};
SearcherDaoPaginatedResult paginatedContentsId2 = this._contentManager.getPaginatedWorkContentsId(null, true, filters2, groupCodes);
assertNotNull(paginatedContentsId2);
- String[] totalExpected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"};
+ String[] totalExpected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"};
assertEquals(totalExpected2.length, paginatedContentsId2.getCount().intValue());
assertEquals(6, paginatedContentsId2.getList().size());
for (int i = 3; i < (6+3); i++) {
@@ -295,7 +295,9 @@ void testSearchContents_3() throws Throwable {
assertNotNull(contentIds);
String[] expected = {"ART187", "ART1", "EVN193", "EVN194", "ART180", "RAH1",
"EVN191", "EVN192", "RAH101", "EVN103", "ART104", "ART102", "EVN23",
- "EVN24", "EVN25", "EVN41", "EVN20", "EVN21", "ART111", "ART120", "ART121", "ART122", "ART112", "ALL4"};
+ "EVN24", "EVN25", "EVN41", "EVN20", "EVN21", "ART111", "ART120", "ART121", "ART122", "ART112", "ALL4",
+ "BLT1", "BLT2", "BLT3", "BLT4"};
+ assertEquals(expected.length, contentIds.size());
this.verifyOrder(contentIds, expected);
}
@@ -352,7 +354,7 @@ void testSearchWorkContents() throws Throwable {
groupCodes.add(Group.ADMINS_GROUP_NAME);
contents = this._contentManager.loadWorkContentsId(null, groupCodes);
assertNotNull(contents);
- assertEquals(25, contents.size());
+ assertEquals(29, contents.size());
}
@Test
@@ -592,7 +594,7 @@ void testLoadFullContent() throws Throwable {
@Test
void testGetContentTypes() {
Map smallContentTypes = _contentManager.getSmallContentTypesMap();
- assertEquals(4, smallContentTypes.size());
+ assertEquals(5, smallContentTypes.size());
}
@Test
@@ -642,7 +644,7 @@ void testGetXML() throws Throwable {
@Test
void testLoadPublicContents() throws EntException {
List contents = _contentManager.loadPublicContentsId(null, null, freeGroup);
- assertEquals(15, contents.size());
+ assertEquals(19, contents.size());
}
@Test
@@ -1614,6 +1616,128 @@ private void setBooleanValue(Content content, String[] attributeCodes, Boolean v
booleanAttribute.setBooleanValue(value);
}
}
+
+ /**
+ * End-to-end DB-search test for a plain Boolean nested inside a Composite: once the child is
+ * flagged searchable, it must be indexed under the path key "Composite_Boolean" and be filterable
+ * through both the work ({@code workcontentsearch}) and public ({@code contentsearch}) searchers -
+ * the Solr-disabled counterpart of the AdvContentSearch nested-boolean capability.
+ */
+ @Test
+ void testLoadContentsByNestedCompositeBooleanAttribute() throws Throwable {
+ this.setCompositeChildrenSearchable(true);
+ List addedContents = new ArrayList<>();
+ try {
+ // After the type-config round-trip, every boolean-like composite child (Boolean, CheckBox,
+ // ThreeState) keeps its inherited searchable flag; non-boolean children are forced false.
+ Content reloaded = this._contentManager.createContentType("ALL");
+ CompositeAttribute reloadedComposite = (CompositeAttribute) reloaded.getAttribute("Composite");
+ assertTrue(reloadedComposite.getAttribute("Boolean").isSearchable());
+ assertTrue(reloadedComposite.getAttribute("CheckBox").isSearchable());
+ assertTrue(reloadedComposite.getAttribute("ThreeState").isSearchable());
+
+ // feature: the nested boolean, made searchable purely through the content type, is indexed
+ // under the path key "Composite_Boolean" and filterable through both DB searchers.
+ String trueId = this.createAllCloneWithNestedBoolean(Boolean.TRUE, addedContents);
+ String falseId = this.createAllCloneWithNestedBoolean(Boolean.FALSE, addedContents);
+
+ // work table (workcontentsearch)
+ List workTrue = this.searchByNestedComposite(true, false);
+ assertTrue(workTrue.contains(trueId));
+ assertFalse(workTrue.contains(falseId));
+ List workFalse = this.searchByNestedComposite(false, false);
+ assertTrue(workFalse.contains(falseId));
+ assertFalse(workFalse.contains(trueId));
+
+ // public table (contentsearch)
+ List onlineTrue = this.searchByNestedComposite(true, true);
+ assertTrue(onlineTrue.contains(trueId));
+ assertFalse(onlineTrue.contains(falseId));
+ List onlineFalse = this.searchByNestedComposite(false, true);
+ assertTrue(onlineFalse.contains(falseId));
+ assertFalse(onlineFalse.contains(trueId));
+ } finally {
+ for (String id : addedContents) {
+ this._contentManager.deleteContent(id);
+ assertNull(this._contentManager.loadContent(id, false));
+ }
+ this.setCompositeChildrenSearchable(false);
+ }
+ }
+
+ @Test
+ void testBooleanSearchableConfigParityTopLevelAndComposite() throws Throwable {
+ // A boolean-like is configured the SAME WAY top-level and as a Composite child: the searchable
+ // flag set on the content type is reported in the type XML (proven by surviving the
+ // serialize->reload of updateEntityPrototype) and inherited by content instances - identically
+ // at both nesting levels. No auto-forcing: an unflagged boolean-like stays non-searchable.
+ Content prototype = this._contentManager.createContentType("ALL");
+ prototype.getAttribute("Boolean").setSearchable(true);
+ ((CompositeAttribute) prototype.getAttribute("Composite")).getAttribute("Boolean").setSearchable(true);
+ try {
+ ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(prototype);
+ this._contentManager.reloadEntitiesReferences("ALL");
+ super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX);
+
+ // reloaded TYPE keeps the flag at both levels -> the type XML reported searchable="true"
+ Content reloadedType = this._contentManager.createContentType("ALL");
+ CompositeAttribute reloadedComposite = (CompositeAttribute) reloadedType.getAttribute("Composite");
+ assertTrue(reloadedType.getAttribute("Boolean").isSearchable(), "top-level boolean searchable preserved");
+ assertTrue(reloadedComposite.getAttribute("Boolean").isSearchable(), "composite boolean searchable preserved");
+ // parity (negative): a boolean-like left unflagged is non-searchable at both levels
+ assertFalse(reloadedType.getAttribute("CheckBox").isSearchable());
+ assertFalse(reloadedComposite.getAttribute("CheckBox").isSearchable());
+
+ // a CONTENT instance inherits the flag from the type, at both levels
+ Content content = this._contentManager.loadContent("ALL4", false);
+ CompositeAttribute contentComposite = (CompositeAttribute) content.getAttribute("Composite");
+ assertTrue(content.getAttribute("Boolean").isSearchable(), "top-level boolean inherited by content");
+ assertTrue(contentComposite.getAttribute("Boolean").isSearchable(), "composite boolean inherited by content");
+ } finally {
+ Content restore = this._contentManager.createContentType("ALL");
+ restore.getAttribute("Boolean").setSearchable(false);
+ ((CompositeAttribute) restore.getAttribute("Composite")).getAttribute("Boolean").setSearchable(false);
+ ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(restore);
+ this._contentManager.reloadEntitiesReferences("ALL");
+ super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX);
+ }
+ }
+
+ private void setCompositeChildrenSearchable(boolean searchable) throws Throwable {
+ Content prototype = this._contentManager.createContentType("ALL");
+ CompositeAttribute composite = (CompositeAttribute) prototype.getAttribute("Composite");
+ composite.getAttribute("Boolean").setSearchable(searchable);
+ composite.getAttribute("CheckBox").setSearchable(searchable);
+ composite.getAttribute("ThreeState").setSearchable(searchable);
+ ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(prototype);
+ this._contentManager.reloadEntitiesReferences("ALL");
+ super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX);
+ }
+
+ private String createAllCloneWithNestedBoolean(Boolean value, List addedContents) throws Throwable {
+ Content clone = this._contentManager.loadContent("ALL4", false);
+ clone.setId(null);
+ CompositeAttribute composite = (CompositeAttribute) clone.getAttribute("Composite");
+ BooleanAttribute nestedBoolean = (BooleanAttribute) composite.getAttribute("Boolean");
+ nestedBoolean.setBooleanValue(value);
+ this._contentManager.saveContent(clone);
+ addedContents.add(clone.getId());
+ this._contentManager.insertOnLineContent(clone);
+ return clone.getId();
+ }
+
+ private List searchByNestedComposite(boolean value, boolean online) throws Exception {
+ List groups = new ArrayList<>();
+ groups.add(Group.ADMINS_GROUP_NAME);
+ EntitySearchFilter typeFilter = new EntitySearchFilter<>(
+ IContentManager.ENTITY_TYPE_CODE_FILTER_KEY, false, "ALL", false);
+ EntitySearchFilter nestedFilter = new EntitySearchFilter<>(
+ "Composite_Boolean", true, Boolean.toString(value), false);
+ EntitySearchFilter[] filters = {typeFilter, nestedFilter};
+ return online
+ ? this._contentManager.loadPublicContentsId("ALL", null, filters, groups)
+ : this._contentManager.loadWorkContentsId(filters, groups);
+ }
private void testBooleanAttribute_test4(String booleanAttribute, String[] nullResults, String[] falseResults, String[] trueResults) throws Exception {
EntitySearchFilter filterForTrue = new EntitySearchFilter<>(booleanAttribute, true, "true", false);
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java
index 5b2e5159dd..cda7a0c689 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java
@@ -41,7 +41,7 @@ class TestContentEntityManager extends BaseTestCase {
void testSearchRecords() throws Throwable {
List contents = this._contentManager.searchRecords(null);
assertNotNull(contents);
- assertEquals(25, contents.size());
+ assertEquals(29, contents.size());
EntitySearchFilter typeFilter = new EntitySearchFilter(IContentManager.ENTITY_TYPE_CODE_FILTER_KEY, false, "ART", false);
EntitySearchFilter[] filters1 = {typeFilter};
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java
index cf1e9b38ec..81a308b5a0 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java
@@ -36,7 +36,7 @@ void testGetList() throws Throwable {
String result = this.executeGetList("admin");
assertEquals(Action.SUCCESS, result);
List contents = (List) ((ContentFinderAction)this.getAction()).getContents();
- assertEquals(25, contents.size());
+ assertEquals(29, contents.size());
result = this.executeGetList("editorCoach");
assertEquals(Action.SUCCESS, result);
@@ -63,7 +63,7 @@ void testPerformSearch_1() throws Throwable {
Map params = new HashMap();
this.executeSearch("admin", params);
ContentFinderAction action = (ContentFinderAction) this.getAction();
- String[] order1 = {"ALL4", "ART112","ART122","ART121","ART120","ART111","ART179","EVN21",
+ String[] order1 = {"BLT4", "BLT3", "BLT2", "BLT1", "ALL4", "ART112","ART122","ART121","ART120","ART111","ART179","EVN21",
"EVN20","EVN41","EVN25","EVN24","EVN23","ART102","ART104","EVN103",
"RAH101","EVN192","EVN191","RAH1","ART180","EVN194","EVN193","ART1","ART187"};
List contents = action.getContents();
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java
index f93804f224..f9c4628852 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java
@@ -51,7 +51,7 @@ void testFindContent_1() throws Throwable {
ContentLinkAction action = (ContentLinkAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
+ assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
}
@@ -68,7 +68,7 @@ void testFindContent_2() throws Throwable {
ContentLinkAction action = (ContentLinkAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(24, contentIds.size());//Tutti i contenuti pubblici
+ assertEquals(28, contentIds.size());//Tutti i contenuti pubblici
}
@Test
@@ -83,7 +83,7 @@ void testFindContent_3() throws Throwable {
ContentLinkAction action = (ContentLinkAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra
+ assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra
assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free
@@ -102,7 +102,7 @@ void testFindContent_4() throws Throwable {
ContentLinkAction action = (ContentLinkAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra
+ assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo coach
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java
index 07f4ce9574..8b5e65c6fd 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java
@@ -38,7 +38,7 @@ void testFindContent_1() throws Throwable {
this.initIntroContentLink("admin", "ART1");//Contenuto del gruppo Free
ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
+ assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
}
@@ -48,7 +48,7 @@ void testFindContent_2() throws Throwable {
this.initIntroContentLink("admin", "ART120");//Contenuto del gruppo degli amministratori
ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(24, contentIds.size());//Tutti i contenuti pubblici
+ assertEquals(28, contentIds.size());//Tutti i contenuti pubblici
}
@Test
@@ -56,7 +56,7 @@ void testFindContent_3() throws Throwable {
this.initIntroContentLink("editorCustomers", "ART102");//Contenuto del gruppo customers
ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra
+ assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra
assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free
@@ -68,7 +68,7 @@ void testFindContent_4() throws Throwable {
this.initIntroContentLink("admin", "EVN25");//Contenuto del gruppo coach
ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra
+ assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo coach
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java
index bfe6ac2a68..786c65a99f 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java
@@ -37,7 +37,7 @@ void testFindContent_1() throws Throwable {
ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
+ assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra
assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
}
@@ -49,7 +49,7 @@ void testFindContent_2() throws Throwable {
ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(24, contentIds.size());//Tutti i contenuti pubblici
+ assertEquals(28, contentIds.size());//Tutti i contenuti pubblici
}
@Test
@@ -59,7 +59,7 @@ void testFindContent_3() throws Throwable {
ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction();
List contentIds = action.getContents();
- assertEquals(20, contentIds.size());// Contenuti pubblici liberi o non liberi con customers gruppo extra
+ assertEquals(24, contentIds.size());// Contenuti pubblici liberi o non liberi con customers gruppo extra
assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers
assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free
assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free
@@ -71,7 +71,7 @@ void testPerformSearch() throws Throwable {
Map params = new HashMap();
this.executeParametrizedSearchContents("admin", "pagina_11", "1", null);//Pagina Free
ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction();
- String[] order1 = {"ALL4", "ART121", "EVN21", "EVN20", "EVN25",
+ String[] order1 = {"BLT4", "BLT3", "BLT2", "BLT1", "ALL4", "ART121", "EVN21", "EVN20", "EVN25",
"EVN24", "EVN23", "EVN192", "EVN191", "RAH1",
"ART180", "EVN194", "EVN193", "ART1", "ART187"};
List contents = action.getContents();
diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java
index 752ff2ffcf..bf4dca44e5 100644
--- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java
+++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java
@@ -57,13 +57,13 @@ void testGetEntityPrototypes() throws Throwable {
IEntityTypesAction action = (IEntityTypesAction) this.getAction();
List entityPrototypes = action.getEntityPrototypes();
assertNotNull(entityPrototypes);
- assertEquals(4, entityPrototypes.size());
-
+ assertEquals(5, entityPrototypes.size());
+
IApsEntity firstType = entityPrototypes.get(0);
assertEquals("ART", firstType.getTypeCode());
assertEquals("Articolo rassegna stampa", firstType.getTypeDescr());
-
- IApsEntity lastType = entityPrototypes.get(3);
+
+ IApsEntity lastType = entityPrototypes.get(4);
assertEquals("RAH", lastType.getTypeCode());
assertEquals("Tipo_Semplice", lastType.getTypeDescr());
}
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..749514d367 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
@@ -35,6 +35,7 @@
import com.agiletec.aps.system.common.entity.IEntityTypesConfigurer;
import com.agiletec.aps.system.common.entity.model.EntitySearchFilter;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.ListAttribute;
@@ -2715,6 +2716,66 @@ void testGetReturnsList() throws Exception {
Assertions.assertEquals(payloadSize2, payloadSize);
}
+ @Test
+ void testGetContentsFilteredByNestedCompositeBooleanAttribute() throws Exception {
+ // Solr-disabled end-to-end: a searchable boolean nested in a Composite is filterable via
+ // GET /plugins/cms/contents using the path key "_" as entityAttr.
+ String trueId = null;
+ String falseId = null;
+ try {
+ trueId = this.createPublishedAllWithNestedBoolean(Boolean.TRUE);
+ falseId = this.createPublishedAllWithNestedBoolean(Boolean.FALSE);
+ UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24")
+ .withAuthorization(Group.FREE_GROUP_NAME, "tempRole", Permission.BACKOFFICE).build();
+ String accessToken = mockOAuthInterceptor(user);
+ ResultActions result = mockMvc
+ .perform(get("/plugins/cms/contents")
+ .param("status", IContentService.STATUS_ONLINE)
+ .param("filters[0].entityAttr", "Composite_Boolean")
+ .param("filters[0].operator", "eq")
+ .param("filters[0].value", "true")
+ .param("filters[0].type", "boolean")
+ .param("pageSize", "50")
+ .header("Authorization", "Bearer " + accessToken));
+ result.andExpect(status().isOk());
+ String body = result.andReturn().getResponse().getContentAsString();
+ int size = JsonPath.read(body, "$.payload.size()");
+ List ids = new ArrayList<>();
+ for (int i = 0; i < size; i++) {
+ ids.add(JsonPath.read(body, "$.payload[" + i + "].id"));
+ }
+ Assertions.assertTrue(ids.contains(trueId));
+ Assertions.assertFalse(ids.contains(falseId));
+ } finally {
+ this.deletePublishedContent(trueId);
+ this.deletePublishedContent(falseId);
+ }
+ }
+
+ private String createPublishedAllWithNestedBoolean(Boolean value) throws Exception {
+ Content clone = this.contentManager.loadContent("ALL4", false);
+ clone.setId(null);
+ clone.setMainGroup(Group.FREE_GROUP_NAME);
+ CompositeAttribute composite = (CompositeAttribute) clone.getAttribute("Composite");
+ BooleanAttribute nestedBoolean = (BooleanAttribute) composite.getAttribute("Boolean");
+ nestedBoolean.setSearchable(true);
+ nestedBoolean.setBooleanValue(value);
+ this.contentManager.saveContent(clone);
+ this.contentManager.insertOnLineContent(clone);
+ return clone.getId();
+ }
+
+ private void deletePublishedContent(String id) throws Exception {
+ if (null == id) {
+ return;
+ }
+ Content content = this.contentManager.loadContent(id, false);
+ if (null != content) {
+ this.contentManager.removeOnLineContent(content);
+ this.contentManager.deleteContent(content);
+ }
+ }
+
@Test
void testLoadPublicEvents_1() throws Exception {
UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24")
@@ -4546,8 +4607,8 @@ void testGetContentsStatus() throws Throwable {
UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24")
.withAuthorization(Group.FREE_GROUP_NAME, "tempRole", Permission.BACKOFFICE).build();
String accessToken = mockOAuthInterceptor(user);
- String lastModified = "2014-03-21 17:10:07";
- this.checkStatus(accessToken, 1, 6, 18, 25, lastModified);
+ String lastModified = "2026-01-01 00:00:04";
+ this.checkStatus(accessToken, 1, 6, 22, 29, lastModified);
List newContentIds = new ArrayList();
try {
for (int i = 0; i < 10; i++) {
@@ -4557,7 +4618,7 @@ void testGetContentsStatus() throws Throwable {
newContentIds.add(content.getId());
}
String dateString1 = DateConverter.getFormattedDate(new Date(), SystemConstants.API_DATE_FORMAT);
- this.checkStatus(accessToken, 1+10, 6, 18, 25+10, dateString1);
+ this.checkStatus(accessToken, 1+10, 6, 22, 29+10, dateString1);
synchronized (this) {
this.wait(1000);
@@ -4569,7 +4630,7 @@ void testGetContentsStatus() throws Throwable {
}
String dateString2 = DateConverter.getFormattedDate(new Date(), SystemConstants.API_DATE_FORMAT);
Assertions.assertNotEquals(dateString1, dateString2);
- this.checkStatus(accessToken, 1, 6, 18+10, 25+10, dateString2);
+ this.checkStatus(accessToken, 1, 6, 22+10, 29+10, dateString2);
synchronized (this) {
this.wait(1000);
@@ -4584,7 +4645,7 @@ void testGetContentsStatus() throws Throwable {
synchronized (this) {
this.wait(1000);
}
- this.checkStatus(accessToken, 1, 6+10, 18, 25+10, dateString3);
+ this.checkStatus(accessToken, 1, 6+10, 22, 29+10, dateString3);
} catch (Exception e) {
throw e;
} finally {
@@ -4594,7 +4655,7 @@ void testGetContentsStatus() throws Throwable {
this.contentManager.removeOnLineContent(content);
this.contentManager.deleteContent(id);
}
- this.checkStatus(accessToken, 1, 6, 18, 25, lastModified);
+ this.checkStatus(accessToken, 1, 6, 22, 29, lastModified);
}
}
diff --git a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java
index 38576ea74b..52f969e3c6 100644
--- a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java
+++ b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java
@@ -61,7 +61,7 @@ public void testSearch_1() throws Throwable {
this.executeSearch("admin", params);
ContentFinderAction action = (ContentFinderAction) this.getAction();
SearcherDaoPaginatedResult result = action.getPaginatedContentsId(10);
- assertEquals(25, result.getCount().intValue());
+ assertEquals(29, result.getCount().intValue());
assertEquals(10, result.getList().size());
this.executeSearch("editorCoach", params);
action = (ContentFinderAction) this.getAction();
@@ -102,7 +102,7 @@ public void testSearch_2() throws Throwable {
this.executeSearch("admin", params);
ContentFinderAction action = (ContentFinderAction) this.getAction();
List contents = action.getContents();
- assertEquals(25, contents.size());
+ assertEquals(29, contents.size());
this.executeSearch("editorCoach", params);
action = (ContentFinderAction) this.getAction();
contents = action.getContents();
diff --git a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java
index 074111b1f9..29c16e50cc 100644
--- a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java
+++ b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java
@@ -67,7 +67,7 @@ public void testOpenNew() throws Throwable {
assertEquals(Action.SUCCESS, result);
JpCwIntroNewContentAction action = (JpCwIntroNewContentAction) this.getAction();
List contentTypes = action.getContentTypes();
- assertEquals(3, contentTypes.size());
+ assertEquals(4, contentTypes.size());
for (int i=0; i infos = currAttribute.getSearchInfos(this.getLangManager().getLangs());
- if (currAttribute.isSearchable() && null != infos) {
- for (int i=0; i attributes = entity.getAttributeList();
+ for (int i = 0; i < attributes.size(); i++) {
+ this.addAttributeSearchRecord(id, attributes.get(i), null, false, stat);
}
stat.executeBatch();
}
+
+ /**
+ * Recursively add the search records of an attribute. Elementary attributes are indexed exactly as
+ * before (by their own name, when searchable). Complex attributes are traversed to reach their
+ * elementary attributes - preserving the historical "flattened" behaviour - with one addition: a
+ * plain boolean attribute nested inside a Composite is indexed under the path key
+ * <composite>_<boolean> to avoid name collisions. {@code CheckBoxAttribute}
+ * and {@code ThreeStateAttribute} are excluded, and a boolean reached through a List/Monolist keeps
+ * the legacy plain-name behaviour (its path is not built).
+ * @param id the entity id.
+ * @param attribute the attribute to process.
+ * @param path the composite name path accumulated so far ('_'-joined), or null when at top level.
+ * @param listAncestor true when a List/Monolist is on the ancestry chain (disables path building).
+ * @param stat the batch statement to fill.
+ * @throws Throwable in case of error.
+ */
+ private void addAttributeSearchRecord(String id, AttributeInterface attribute, String path,
+ boolean listAncestor, PreparedStatement stat) throws Throwable {
+ if (attribute.isSimple()) {
+ String attrName = (!listAncestor && null != path
+ && NestedBooleanSearchSupport.isIndexableNestedBoolean(attribute))
+ ? path + "_" + attribute.getName()
+ : attribute.getName();
+ List infos = attribute.getSearchInfos(this.getLangManager().getLangs());
+ if (attribute.isSearchable() && null != infos) {
+ this.addAttributeSearchInfoRecords(id, attrName, infos, stat);
+ }
+ } else {
+ List children = ((AbstractComplexAttribute) attribute).getAttributes();
+ if (null == children) {
+ return;
+ }
+ boolean composite = (attribute instanceof CompositeAttribute) && !listAncestor;
+ String childPath = composite
+ ? ((null == path) ? attribute.getName() : path + "_" + attribute.getName())
+ : null;
+ boolean childListAncestor = listAncestor || !(attribute instanceof CompositeAttribute);
+ for (int i = 0; i < children.size(); i++) {
+ this.addAttributeSearchRecord(id, children.get(i), childPath, childListAncestor, stat);
+ }
+ }
+ }
+
+ private void addAttributeSearchInfoRecords(String id, String attrName,
+ List infos, PreparedStatement stat) throws SQLException {
+ for (int i = 0; i < infos.size(); i++) {
+ AttributeSearchInfo searchInfo = infos.get(i);
+ stat.setString(1, id);
+ stat.setString(2, attrName);
+ stat.setString(3, searchInfo.getString());
+ if (searchInfo.getDate() != null) {
+ stat.setTimestamp(4, new java.sql.Timestamp(searchInfo.getDate().getTime()));
+ } else {
+ stat.setDate(4, null);
+ }
+ stat.setBigDecimal(5, searchInfo.getBigDecimal());
+ stat.setString(6, searchInfo.getLangCode());
+ stat.addBatch();
+ stat.clearParameters();
+ }
+ }
protected void addEntityAttributeRoleRecord(String id, IApsEntity entity, Connection conn) {
PreparedStatement stat = null;
diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java
new file mode 100644
index 0000000000..116c6a4980
--- /dev/null
+++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.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.entity;
+
+import java.util.List;
+
+import com.agiletec.aps.system.common.entity.model.IApsEntity;
+import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute;
+
+/**
+ * Single source of truth for indexing/searching boolean attributes nested inside a Composite
+ * attribute in the DB search tables ({@code contentsearch} / {@code workcontentsearch}).
+ *
+ *
A boolean nested in a Composite is indexed under the path key {@code _}
+ * (composite names joined by '_') to avoid name collisions. All boolean-like attributes are eligible -
+ * {@link BooleanAttribute} and its subclasses {@code CheckBoxAttribute} and {@code ThreeStateAttribute} -
+ * governed by the {@code searchable} flag inherited from the content type. Only Composite
+ * ancestry is supported: a boolean reached through a {@code MonoListAttribute}/{@code ListAttribute} is
+ * not path-indexed.
+ *
+ *
The write side ({@code AbstractEntityDAO.addEntitySearchRecord}) and the filter-key resolution
+ * ({@code EntitySearchFilter.getInstance}) share this class so that the key produced by the writer is
+ * exactly the key the reader resolves. It also gates {@code CompositeAttribute}'s decision to preserve
+ * the {@code searchable} flag on a composite child.
+ *
+ * @author Entando
+ */
+public final class NestedBooleanSearchSupport {
+
+ private NestedBooleanSearchSupport() {
+ // utility class
+ }
+
+ /**
+ * Whether the given attribute is a boolean-like attribute eligible for nested (path-based) DB
+ * indexing, i.e. a {@link BooleanAttribute} or one of its subclasses (CheckBox, ThreeState).
+ * @param attribute the attribute to test.
+ * @return true if the attribute is boolean-like.
+ */
+ public static boolean isIndexableNestedBoolean(AttributeInterface attribute) {
+ return attribute instanceof BooleanAttribute;
+ }
+
+ /**
+ * Resolve a Composite-nested boolean attribute from its path key {@code _}.
+ * The traversal descends only through Composite children (never lists) and builds the same
+ * path the writer uses, so resolution matches indexing exactly.
+ * @param entity the entity (or type prototype) to inspect.
+ * @param key the underscore path key.
+ * @return the matching nested plain boolean attribute, or null if none matches.
+ */
+ public static AttributeInterface resolveNestedBooleanByKey(IApsEntity entity, String key) {
+ if (null == entity || null == key) {
+ return null;
+ }
+ return resolve(entity.getAttributeList(), null, key);
+ }
+
+ private static AttributeInterface resolve(List attributes, String path, String key) {
+ if (null == attributes) {
+ return null;
+ }
+ for (int i = 0; i < attributes.size(); i++) {
+ AttributeInterface attribute = attributes.get(i);
+ if (attribute.isSimple()) {
+ if (null != path && isIndexableNestedBoolean(attribute)
+ && key.equals(path + "_" + attribute.getName())) {
+ return attribute;
+ }
+ } else if (attribute instanceof CompositeAttribute) {
+ String childPath = (null == path) ? attribute.getName() : path + "_" + attribute.getName();
+ AttributeInterface found = resolve(((AbstractComplexAttribute) attribute).getAttributes(), childPath, key);
+ if (null != found) {
+ return found;
+ }
+ }
+ // Lists (MonoList/List) and any other complex type are intentionally not descended:
+ // list-reached booleans are out of scope for path indexing.
+ }
+ return null;
+ }
+
+}
diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java
index 4686db3c9e..b6b7153162 100644
--- a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java
+++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java
@@ -28,6 +28,7 @@
import org.entando.entando.ent.util.EntLogging.EntLogFactory;
import com.agiletec.aps.system.common.FieldSearchFilter;
+import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute;
@@ -371,6 +372,11 @@ public static EntitySearchFilter getInstance(IApsEntity prototype, Properties pr
AttributeInterface attr = null;
if (null != key) {
attr = (AttributeInterface) prototype.getAttribute(key);
+ if (null == attr) {
+ // fall back to a Composite-nested boolean referenced by its path key
+ // '_' (top-level attributes always take precedence)
+ attr = NestedBooleanSearchSupport.resolveNestedBooleanByKey(prototype, key);
+ }
filter.setKey(key);
} else {
attr = (AttributeInterface) prototype.getAttributeByRole(roleName);
diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java
index 79522df83c..9779a3ff98 100644
--- a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java
+++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java
@@ -13,6 +13,7 @@
*/
package com.agiletec.aps.system.common.entity.model.attribute;
+import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport;
import com.agiletec.aps.system.common.entity.model.AttributeFieldError;
import com.agiletec.aps.system.common.entity.model.AttributeTracer;
import com.agiletec.aps.system.common.entity.model.IApsEntity;
@@ -183,7 +184,12 @@ private void extractAttributeCompositeElement(Map at
}
compositeAttrElem = (AttributeInterface) compositeAttrElem.getAttributePrototype();
compositeAttrElem.setAttributeConfig(currentAttrJdomElem);
- compositeAttrElem.setSearchable(false);
+ // Composite children are non-searchable by design (they would collide in the DB search tables
+ // under their unqualified name), EXCEPT plain boolean children, which are indexed under the
+ // path key "_" and so may keep their configured searchable flag.
+ if (!NestedBooleanSearchSupport.isIndexableNestedBoolean(compositeAttrElem)) {
+ compositeAttrElem.setSearchable(false);
+ }
compositeAttrElem.setDefaultLangCode(this.getDefaultLangCode());
this.addAttribute(compositeAttrElem);
}
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java
new file mode 100644
index 0000000000..67aad31d5c
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java
@@ -0,0 +1,251 @@
+/*
+ * 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.entity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.agiletec.aps.system.common.entity.model.ApsEntity;
+import com.agiletec.aps.system.common.entity.model.ApsEntityRecord;
+import com.agiletec.aps.system.common.entity.model.IApsEntity;
+import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute;
+import com.agiletec.aps.system.services.lang.ILangManager;
+import com.agiletec.aps.system.services.lang.Lang;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Verifies the {@code attrname} values that {@link AbstractEntityDAO#addEntitySearchRecord} writes to
+ * the DB search tables, focusing on the new behaviour: a plain boolean nested in a Composite is stored
+ * under the path key {@code _}, while everything else keeps its historical name.
+ */
+class AbstractEntityDAONestedBooleanTest {
+
+ private TestEntityDAO dao;
+ private PreparedStatement stat;
+
+ @BeforeEach
+ void setUp() {
+ this.dao = new TestEntityDAO();
+ ILangManager langManager = mock(ILangManager.class);
+ Lang en = new Lang();
+ en.setCode("en");
+ en.setDescr("English");
+ when(langManager.getLangs()).thenReturn(Collections.singletonList(en));
+ this.dao.setLangManager(langManager);
+ this.stat = mock(PreparedStatement.class);
+ }
+
+ @Test
+ void topLevelSearchableBooleanKeepsPlainName() throws Throwable {
+ assertEquals(List.of("flag"),
+ writtenAttrNames(entity(booleanAttr("flag", true, Boolean.TRUE))));
+ }
+
+ @Test
+ void compositeSearchableBooleanUsesPathName() throws Throwable {
+ assertEquals(List.of("address_certified"),
+ writtenAttrNames(entity(composite("address", booleanAttr("certified", true, Boolean.TRUE)))));
+ }
+
+ @Test
+ void compositeNonSearchableBooleanIsNotIndexed() throws Throwable {
+ assertEquals(List.of(),
+ writtenAttrNames(entity(composite("address", booleanAttr("certified", false, Boolean.TRUE)))));
+ }
+
+ @Test
+ void deepCompositeBooleanUsesFullPathName() throws Throwable {
+ assertEquals(List.of("a_b_c"),
+ writtenAttrNames(entity(composite("a", composite("b", booleanAttr("c", true, Boolean.TRUE))))));
+ }
+
+ @Test
+ void nestedCheckBoxIsPathIndexed() throws Throwable {
+ // CheckBox is a boolean-like -> path-qualified like Boolean (value null -> "false")
+ assertEquals(List.of("address_verified"),
+ writtenAttrNames(entity(composite("address", checkBox("verified", true)))));
+ }
+
+ @Test
+ void nestedThreeStateIsPathIndexed() throws Throwable {
+ ThreeStateAttribute maybe = threeState("maybe", true);
+ maybe.setBooleanValue(Boolean.TRUE); // non-null so ThreeState produces a search row
+ assertEquals(List.of("address_maybe"),
+ writtenAttrNames(entity(composite("address", maybe))));
+ }
+
+ @Test
+ void listReachedBooleanKeepsPlainName() throws Throwable {
+ assertEquals(List.of("flag"),
+ writtenAttrNames(entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE)))));
+ }
+
+ @Test
+ void listOfCompositeBooleanKeepsPlainName() throws Throwable {
+ assertEquals(List.of("active"),
+ writtenAttrNames(entity(monolist("rows", composite("row", booleanAttr("active", true, Boolean.TRUE))))));
+ }
+
+ @Test
+ void mixedEntityWritesEachAttributeUnderItsExpectedName() throws Throwable {
+ ApsEntity entity = entity(
+ booleanAttr("published", true, Boolean.TRUE),
+ composite("address", booleanAttr("certified", true, Boolean.TRUE)));
+ assertEquals(List.of("published", "address_certified"), writtenAttrNames(entity));
+ }
+
+ private List writtenAttrNames(IApsEntity entity) throws Throwable {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
+ this.dao.addSearchRecords("ENTITY1", entity, this.stat);
+ verify(this.stat, atLeast(0)).setString(eq(2), captor.capture());
+ return captor.getAllValues();
+ }
+
+ // --- fixtures ----------------------------------------------------------
+
+ private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) {
+ BooleanAttribute a = new BooleanAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ a.setBooleanValue(value);
+ return a;
+ }
+
+ private CheckBoxAttribute checkBox(String name, boolean searchable) {
+ CheckBoxAttribute a = new CheckBoxAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ return a;
+ }
+
+ private ThreeStateAttribute threeState(String name, boolean searchable) {
+ ThreeStateAttribute a = new ThreeStateAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ return a;
+ }
+
+ private CompositeAttribute composite(String name, AttributeInterface... children) {
+ CompositeAttribute c = new CompositeAttribute();
+ c.setName(name);
+ for (AttributeInterface child : children) {
+ c.getAttributes().add(child);
+ }
+ return c;
+ }
+
+ private MonoListAttribute monolist(String name, AttributeInterface... elements) {
+ MonoListAttribute list = new MonoListAttribute();
+ list.setName(name);
+ for (AttributeInterface element : elements) {
+ list.getAttributes().add(element);
+ }
+ return list;
+ }
+
+ private ApsEntity entity(AttributeInterface... attributes) {
+ ApsEntity entity = new ApsEntity();
+ entity.setTypeCode("TST");
+ for (AttributeInterface attribute : attributes) {
+ entity.addAttribute(attribute);
+ }
+ return entity;
+ }
+
+ /**
+ * Minimal concrete {@link AbstractEntityDAO} exposing the protected search-record writer.
+ */
+ private static class TestEntityDAO extends AbstractEntityDAO {
+
+ public void addSearchRecords(String id, IApsEntity entity, PreparedStatement stat) throws Throwable {
+ this.addEntitySearchRecord(id, entity, stat);
+ }
+
+ @Override
+ protected String getAddEntityRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected void buildAddEntityStatement(IApsEntity entity, PreparedStatement stat) throws Throwable {
+ // no-op
+ }
+
+ @Override
+ protected String getDeleteEntityRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected String getUpdateEntityRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected void buildUpdateEntityStatement(IApsEntity entity, PreparedStatement stat) throws Throwable {
+ // no-op
+ }
+
+ @Override
+ protected String getLoadEntityRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected ApsEntityRecord createEntityRecord(ResultSet res) throws Throwable {
+ return null;
+ }
+
+ @Override
+ protected String getAddingSearchRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected String getAddingAttributeRoleRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected String getRemovingSearchRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected String getRemovingAttributeRoleRecordQuery() {
+ return null;
+ }
+
+ @Override
+ protected String getExtractingAllEntityIdQuery() {
+ return null;
+ }
+ }
+
+}
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java
new file mode 100644
index 0000000000..b961ee01e7
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.entity;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.agiletec.aps.system.common.entity.model.ApsEntity;
+import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.MonoTextAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link NestedBooleanSearchSupport} - the shared logic used to index/resolve
+ * boolean attributes nested inside a Composite in the DB search tables (Solr-disabled path).
+ */
+class NestedBooleanSearchSupportTest {
+
+ @Test
+ void isIndexableNestedBoolean_shouldAcceptAllBooleanLikes() {
+ assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(booleanAttr("b", true, Boolean.TRUE)));
+ assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(checkBox("c", true)));
+ assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(threeState("t", true)));
+ assertFalse(NestedBooleanSearchSupport.isIndexableNestedBoolean(new MonoTextAttribute()));
+ assertFalse(NestedBooleanSearchSupport.isIndexableNestedBoolean(null));
+ }
+
+ @Test
+ void shouldResolveCompositeNestedBoolean() {
+ BooleanAttribute certified = booleanAttr("certified", true, Boolean.TRUE);
+ ApsEntity entity = entity(composite("address", certified));
+ assertSame(certified, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_certified"));
+ }
+
+ @Test
+ void shouldResolveDeepCompositePath() {
+ BooleanAttribute leaf = booleanAttr("c", true, Boolean.TRUE);
+ ApsEntity entity = entity(composite("a", composite("b", leaf)));
+ assertSame(leaf, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "a_b_c"));
+ }
+
+ @Test
+ void shouldReturnNullForUnknownKey() {
+ ApsEntity entity = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE)));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_missing"));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "nope"));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(null, "address_certified"));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, null));
+ }
+
+ @Test
+ void shouldResolveNestedCheckBoxAndThreeState() {
+ CheckBoxAttribute verified = checkBox("verified", true);
+ ThreeStateAttribute maybe = threeState("maybe", true);
+ ApsEntity entity = entity(composite("address", verified, maybe));
+ assertSame(verified, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_verified"));
+ assertSame(maybe, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_maybe"));
+ }
+
+ @Test
+ void shouldNotResolveListReachedBoolean() {
+ ApsEntity listOfBoolean = entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE)));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(listOfBoolean, "tags_flag"));
+
+ ApsEntity listOfComposite = entity(monolist("rows", composite("row", booleanAttr("active", true, Boolean.TRUE))));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(listOfComposite, "rows_row_active"));
+ }
+
+ @Test
+ void shouldNotResolveTopLevelAttribute() {
+ // the resolver only matches nested booleans; top-level precedence is handled by the caller
+ ApsEntity entity = entity(booleanAttr("flag", true, Boolean.TRUE));
+ assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "flag"));
+ }
+
+ // --- helpers -----------------------------------------------------------
+
+ private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) {
+ BooleanAttribute a = new BooleanAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ a.setBooleanValue(value);
+ return a;
+ }
+
+ private CheckBoxAttribute checkBox(String name, boolean searchable) {
+ CheckBoxAttribute a = new CheckBoxAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ return a;
+ }
+
+ private ThreeStateAttribute threeState(String name, boolean searchable) {
+ ThreeStateAttribute a = new ThreeStateAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ return a;
+ }
+
+ private CompositeAttribute composite(String name, AttributeInterface... children) {
+ CompositeAttribute c = new CompositeAttribute();
+ c.setName(name);
+ for (AttributeInterface child : children) {
+ c.getAttributes().add(child);
+ }
+ return c;
+ }
+
+ private MonoListAttribute monolist(String name, AttributeInterface... elements) {
+ MonoListAttribute list = new MonoListAttribute();
+ list.setName(name);
+ for (AttributeInterface element : elements) {
+ list.getAttributes().add(element);
+ }
+ return list;
+ }
+
+ private ApsEntity entity(AttributeInterface... attributes) {
+ ApsEntity entity = new ApsEntity();
+ entity.setTypeCode("TST");
+ for (AttributeInterface attribute : attributes) {
+ entity.addAttribute(attribute);
+ }
+ return entity;
+ }
+
+}
diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java
new file mode 100644
index 0000000000..7ffd7b9152
--- /dev/null
+++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.entity.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute;
+import java.util.Properties;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that {@link EntitySearchFilter#getInstance(IApsEntity, Properties)} accepts a
+ * Composite-nested boolean referenced by its path key {@code _} - the read-side
+ * half of the Solr-disabled nested-boolean feature.
+ */
+class EntitySearchFilterNestedBooleanTest {
+
+ @Test
+ void shouldResolveNestedBooleanPathKey() {
+ ApsEntity prototype = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE)));
+ EntitySearchFilter filter = EntitySearchFilter.getInstance(prototype,
+ attributeFilterProps("address_certified", "true"));
+ assertNotNull(filter);
+ assertTrue(filter.isAttributeFilter());
+ assertEquals("address_certified", filter.getKey());
+ assertEquals("true", filter.getValue());
+ }
+
+ @Test
+ void topLevelAttributeTakesPrecedence() {
+ ApsEntity prototype = entity(booleanAttr("flag", true, Boolean.TRUE));
+ EntitySearchFilter filter = EntitySearchFilter.getInstance(prototype,
+ attributeFilterProps("flag", "true"));
+ assertNotNull(filter);
+ assertEquals("flag", filter.getKey());
+ }
+
+ @Test
+ void shouldRejectUnknownKey() {
+ ApsEntity prototype = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE)));
+ assertThrows(RuntimeException.class, () -> EntitySearchFilter.getInstance(prototype,
+ attributeFilterProps("address_missing", "true")));
+ }
+
+ @Test
+ void shouldNotResolveListReachedBoolean() {
+ ApsEntity prototype = entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE)));
+ assertThrows(RuntimeException.class, () -> EntitySearchFilter.getInstance(prototype,
+ attributeFilterProps("tags_flag", "true")));
+ }
+
+ // --- helpers -----------------------------------------------------------
+
+ private Properties attributeFilterProps(String key, String value) {
+ Properties props = new Properties();
+ props.setProperty(EntitySearchFilter.KEY_PARAM, key);
+ props.setProperty(EntitySearchFilter.FILTER_TYPE_PARAM, Boolean.TRUE.toString());
+ props.setProperty(EntitySearchFilter.VALUE_PARAM, value);
+ return props;
+ }
+
+ private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) {
+ BooleanAttribute a = new BooleanAttribute();
+ a.setName(name);
+ a.setSearchable(searchable);
+ a.setBooleanValue(value);
+ return a;
+ }
+
+ private CompositeAttribute composite(String name, AttributeInterface... children) {
+ CompositeAttribute c = new CompositeAttribute();
+ c.setName(name);
+ for (AttributeInterface child : children) {
+ c.getAttributes().add(child);
+ }
+ return c;
+ }
+
+ private MonoListAttribute monolist(String name, AttributeInterface... elements) {
+ MonoListAttribute list = new MonoListAttribute();
+ list.setName(name);
+ for (AttributeInterface element : elements) {
+ list.getAttributes().add(element);
+ }
+ return list;
+ }
+
+ private ApsEntity entity(AttributeInterface... attributes) {
+ ApsEntity entity = new ApsEntity();
+ entity.setTypeCode("TST");
+ for (AttributeInterface attribute : attributes) {
+ entity.addAttribute(attribute);
+ }
+ return entity;
+ }
+
+}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java
index 1644d79623..277146793d 100644
--- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java
@@ -34,6 +34,8 @@
import org.entando.entando.ent.util.EntLogging.EntLogger;
import org.entando.entando.web.common.exceptions.ValidationConflictException;
import org.entando.entando.web.common.model.Filter;
+import org.entando.entando.web.common.model.FilterOperator;
+import org.entando.entando.web.common.model.FilterType;
import org.entando.entando.plugins.jpsolr.aps.system.solr.ISolrSearchEngineManager;
import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFacetedContentsResult;
import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrSearchEngineFilter;
@@ -197,6 +199,45 @@ private void validateFilters(Filter[] filters, String fieldPrefix,
rejectIfUnsafeIdentifier(solrFilter.getSearchOption(),
fieldPrefix + ".searchOption", bindingResult);
}
+ rejectIfInvalidBooleanFilter(filter, fieldPrefix, bindingResult);
+ }
+ }
+
+ /**
+ * Booleans have no meaningful range and only two valid values. Without this check a range
+ * operator silently builds a nonsense query (SearcherDAO's string-range fallback), and
+ * {@code Boolean.parseBoolean} silently coerces any non-"true" string (including a
+ * three-state "none") to {@code false} instead of failing.
+ */
+ private void rejectIfInvalidBooleanFilter(Filter filter, String fieldPrefix,
+ BeanPropertyBindingResult bindingResult) {
+ if (!FilterType.BOOLEAN.getValue().equalsIgnoreCase(filter.getType())) {
+ return;
+ }
+ String operator = filter.getOperator();
+ if (FilterOperator.GREATER.getValue().equalsIgnoreCase(operator)
+ || FilterOperator.LOWER.getValue().equalsIgnoreCase(operator)) {
+ logger.warn("Rejected range operator '{}' on boolean filter in field '{}'", operator, fieldPrefix);
+ bindingResult.rejectValue(null, "INVALID_PARAMETER",
+ new Object[]{fieldPrefix + ".operator"}, "parameter.invalid");
+ }
+ rejectIfNotStrictBoolean(filter.getValue(), fieldPrefix + ".value", bindingResult);
+ if (null != filter.getAllowedValues()) {
+ for (String av : filter.getAllowedValues()) {
+ rejectIfNotStrictBoolean(av, fieldPrefix + ".allowedValues", bindingResult);
+ }
+ }
+ }
+
+ private static void rejectIfNotStrictBoolean(String value, String field,
+ BeanPropertyBindingResult bindingResult) {
+ if (StringUtils.isBlank(value)) {
+ return;
+ }
+ if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) {
+ logger.warn("Rejected non-boolean value in field '{}': '{}'", field, value);
+ bindingResult.rejectValue(null, "INVALID_PARAMETER",
+ new Object[]{field}, "parameter.invalid");
}
}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java
index 3f7849c78e..199da0edd5 100644
--- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java
@@ -16,8 +16,11 @@
import com.agiletec.aps.system.common.entity.model.IApsEntity;
import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface;
import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute;
import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface;
import com.agiletec.aps.system.common.tree.ITreeNode;
import com.agiletec.aps.system.common.tree.ITreeNodeManager;
@@ -131,7 +134,10 @@ protected SolrInputDocument createDocument(IApsEntity entity) {
}
for (AttributeInterface currentAttribute : entity.getAttributeList()) {
Object value = currentAttribute.getValue();
- if (null == value) {
+ // An uninitialized ThreeStateAttribute must still reach indexAttribute so it is
+ // indexed as the literal "none": its getValue() returns null on purpose (the third,
+ // unset state), unlike a plain BooleanAttribute which coerces null to false.
+ if (null == value && !(currentAttribute instanceof ThreeStateAttribute)) {
continue;
}
for (Lang lang : this.getLangManager().getLangs()) {
@@ -165,12 +171,13 @@ protected void extractCategoryCodes(ITreeNode category, Set codes) {
protected void indexAttribute(SolrInputDocument document, AttributeInterface attribute, Lang lang) {
attribute.setRenderingLang(lang.getCode());
if (!attribute.isSimple()) {
- this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang);
+ this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang,
+ attribute.getName(), false);
return;
}
if (attribute instanceof IndexableAttributeInterface
- || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute)
- && attribute.isSearchable())) {
+ || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute
+ || attribute instanceof BooleanAttribute) && attribute.isSearchable())) {
Object valueToIndex = null;
if (attribute instanceof DateAttribute) {
valueToIndex = ((DateAttribute) attribute).getDate();
@@ -179,6 +186,11 @@ protected void indexAttribute(SolrInputDocument document, AttributeInterface att
if (null != valueToIndex) {
valueToIndex = ((BigDecimal) valueToIndex).intValue();
}
+ } else if (attribute instanceof ThreeStateAttribute) {
+ // Must be tested before BooleanAttribute (ThreeStateAttribute is a subclass).
+ valueToIndex = SolrComplexAttributes.solrValue(attribute);
+ } else if (attribute instanceof BooleanAttribute) {
+ valueToIndex = SolrComplexAttributes.solrValue(attribute);
} else {
valueToIndex = ((IndexableAttributeInterface) attribute).getIndexeableFieldValue();
}
@@ -204,14 +216,27 @@ protected void indexAttribute(SolrInputDocument document, AttributeInterface att
}
private void indexComplexAttribute(SolrInputDocument document, AbstractComplexAttribute complexAttribute,
- Lang lang) {
+ Lang lang, String namePrefix, boolean withinList) {
+ // Text children are still routed to the full-text "" field wherever they occur
+ // (including inside lists). Per-attribute boolean fields are created for Composite children
+ // only: once the traversal has entered a List/Monolist, booleans are skipped so the indexer
+ // never writes a field the schema (SolrFieldsChecker) does not create.
+ boolean insideList = withinList || (complexAttribute instanceof ListAttributeInterface);
for (AttributeInterface attribute : complexAttribute.getAttributes()) {
attribute.setRenderingLang(lang.getCode());
+ String path = SolrComplexAttributes.appendPath(namePrefix, attribute.getName());
if (!attribute.isSimple()) {
- this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang);
+ this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang, path, insideList);
} else if (attribute instanceof IndexableAttributeInterface){
String valueToIndex = ((IndexableAttributeInterface) attribute).getIndexeableFieldValue();
this.addFieldForFullTextSearch(document, attribute, lang, valueToIndex);
+ } else if (!insideList
+ && SolrComplexAttributes.isIndexableCompositeBoolean(attribute)) {
+ // solrValue() never returns null: an unset ThreeStateAttribute becomes the
+ // literal "none", an unset Boolean/CheckBoxAttribute coerces to false.
+ Object valueToIndex = SolrComplexAttributes.solrValue(attribute);
+ String fieldName = lang.getCode().toLowerCase() + "_" + path;
+ this.indexValue(document, fieldName, valueToIndex);
}
}
}
@@ -232,6 +257,7 @@ protected void addFieldForFullTextSearch(SolrInputDocument document, AttributeIn
private void indexValue(SolrInputDocument document, String fieldName, Object valueToIndex) {
fieldName = fieldName.replace(":", "_");
+ logger.debug("Indexing attribute field '{}' with value '{}'", fieldName, valueToIndex);
document.addField(fieldName, valueToIndex);
}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java
index db41072738..fee28301f8 100644
--- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java
@@ -375,7 +375,7 @@ private Query createMultipleValuesQuery(SearchEngineFilter> filter, String key
}
//To be improved to manage different type
for (Object singleValue : allowedValues) {
- if (filter instanceof NumericSearchEngineFilter) {
+ if (filter instanceof NumericSearchEngineFilter || singleValue instanceof Boolean) {
TermQuery term = new TermQuery(new Term(key, singleValue + relevance));
fieldQuery.add(term, BooleanClause.Occur.SHOULD);
} else {
@@ -457,6 +457,9 @@ private Query createSingleValueQuery(SearchEngineFilter> filter, String key, S
} else if (value instanceof Number) {
TermQuery term = new TermQuery(new Term(key, value + relevance));
fieldQuery.add(term, BooleanClause.Occur.MUST);
+ } else if (value instanceof Boolean) {
+ TermQuery term = new TermQuery(new Term(key, value.toString() + relevance));
+ fieldQuery.add(term, BooleanClause.Occur.MUST);
}
return fieldQuery.build();
}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java
new file mode 100644
index 0000000000..c420c53f3d
--- /dev/null
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2021-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 org.entando.entando.plugins.jpsolr.aps.system.solr;
+
+import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute;
+import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields;
+
+/**
+ * Shared rules for indexing boolean-like attributes (Boolean, CheckBox, ThreeState) nested inside
+ * Composite attributes.
+ *
+ *
Kept in one place so the schema side ({@code SolrFieldsChecker}), the document side
+ * ({@code IndexerDAO}) and the content-type settings report ({@code SolrSearchEngineManager}) build
+ * identical field names and apply the same "is this boolean indexable" decision. Any divergence
+ * would create fields the indexer never populates, or make the validity check loop forever.
+ *
+ *
List and Monolist attributes are intentionally out of scope: booleans reached through a list
+ * are never indexed as per-attribute fields.
+ *
+ *
Public (rather than package-private) because {@code ContentTypeSettings}, which needs the
+ * same type/value dispatch, lives in the {@code .model} sub-package.
+ */
+public final class SolrComplexAttributes {
+
+ private SolrComplexAttributes() {
+ }
+
+ /**
+ * A boolean-like attribute ({@code BooleanAttribute} or a subclass — {@code CheckBoxAttribute},
+ * {@code ThreeStateAttribute}) nested in a Composite is indexed when its {@code searchable} flag,
+ * inherited from the content type, is set. This mirrors how top-level boolean-like attributes are
+ * gated, so nested and top-level behave uniformly.
+ */
+ static boolean isIndexableCompositeBoolean(AttributeInterface attribute) {
+ return attribute instanceof BooleanAttribute && attribute.isSearchable();
+ }
+
+ /**
+ * The Solr field type for a boolean-like attribute. {@code ThreeStateAttribute} must be tested
+ * before {@code BooleanAttribute} (it is a subclass): its third "uninitialized" state cannot be
+ * represented in Solr's two-valued {@code BoolField}, so it is indexed as a non-analyzed {@code
+ * string} field with the literal values {@code true}/{@code false}/{@code none}; plain {@code
+ * BooleanAttribute}/{@code CheckBoxAttribute} are indexed as Solr {@code boolean}.
+ */
+ public static String solrType(AttributeInterface attribute) {
+ if (attribute instanceof ThreeStateAttribute) {
+ return SolrFields.TYPE_STRING;
+ }
+ return SolrFields.TYPE_BOOLEAN;
+ }
+
+ /**
+ * The value to write for a boolean-like attribute. {@code ThreeStateAttribute}: {@code null}
+ * (uninitialized) becomes the literal {@code "none"}, otherwise the lowercase string literal
+ * {@code "true"}/{@code "false"}. Plain {@code BooleanAttribute}/{@code CheckBoxAttribute}:
+ * {@code getValue()} already coerces {@code null} to {@code false}.
+ */
+ public static Object solrValue(AttributeInterface attribute) {
+ if (attribute instanceof ThreeStateAttribute) {
+ Boolean value = ((ThreeStateAttribute) attribute).getValue();
+ return (null == value) ? "none" : value.toString();
+ }
+ return ((BooleanAttribute) attribute).getValue();
+ }
+
+ /**
+ * Extends a composite name path with a child segment. The resulting field name is
+ * "<lang>_<path>" (built by the callers), e.g. "en_complexAttrName_boolAttrName" for the boolean
+ * "boolAttrName" nested in the composite "complexAttrName".
+ */
+ static String appendPath(String namePrefix, String name) {
+ return namePrefix + "_" + name;
+ }
+}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java
index 74a81f268b..fb368fa6ae 100644
--- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java
@@ -4,9 +4,11 @@
import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_NAME;
import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_TYPE;
+import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute;
+import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface;
import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute;
import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface;
import com.agiletec.aps.system.services.lang.Lang;
@@ -95,33 +97,74 @@ private void checkLangFields() {
private void checkAttribute(AttributeInterface attribute, Lang lang) {
attribute.setRenderingLang(lang.getCode());
- if (attribute instanceof IndexableAttributeInterface
- || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute)
- && attribute.isSearchable())) {
- String type;
- if (attribute instanceof DateAttribute) {
- type = SolrFields.TYPE_PDATES;
- } else if (attribute instanceof NumberAttribute) {
- type = SolrFields.TYPE_PLONGS;
- } else if (attribute instanceof BooleanAttribute) {
- type = SolrFields.TYPE_BOOLEAN;
- } else {
- type = SolrFields.TYPE_TEXT_GEN_SORT;
- }
- String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName();
- fieldName = fieldName.replace(":", "_");
+ if (!attribute.isSimple()) {
+ this.checkComplexAttributeChildren(attribute, lang, attribute.getName());
+ return;
+ }
+ if (this.isIndexableAttributeField(attribute)) {
+ String type = this.getFieldType(attribute);
+ String fieldName = this.buildFieldName(lang, attribute.getName());
this.checkField(fieldName, type);
if (null == attribute.getRoles()) {
return;
}
for (String role : attribute.getRoles()) {
- String roleFieldName = lang.getCode().toLowerCase() + "_" + role;
- roleFieldName = roleFieldName.replace(":", "_");
+ String roleFieldName = this.buildFieldName(lang, role);
this.checkField(roleFieldName, type);
}
}
}
+ private boolean isIndexableAttributeField(AttributeInterface attribute) {
+ return attribute instanceof IndexableAttributeInterface
+ || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute
+ || attribute instanceof BooleanAttribute) && attribute.isSearchable());
+ }
+
+ private String getFieldType(AttributeInterface attribute) {
+ if (attribute instanceof DateAttribute) {
+ return SolrFields.TYPE_PDATES;
+ } else if (attribute instanceof NumberAttribute) {
+ return SolrFields.TYPE_PLONGS;
+ } else if (attribute instanceof BooleanAttribute) {
+ // Covers ThreeStateAttribute too (subclass): SolrComplexAttributes.solrType tests it
+ // first internally and returns TYPE_STRING for it, TYPE_BOOLEAN otherwise.
+ return SolrComplexAttributes.solrType(attribute);
+ } else {
+ return SolrFields.TYPE_TEXT_GEN_SORT;
+ }
+ }
+
+ private String buildFieldName(Lang lang, String name) {
+ return (lang.getCode().toLowerCase() + "_" + name).replace(":", "_");
+ }
+
+ // Nested booleans only, Composite attributes only: List/Monolist attributes are excluded, and
+ // Date/Number/Text children remain full-text-only (unchanged, matching the baseline Lucene
+ // engine). The field name carries the full composite path, e.g. "en_complexAttrName_boolAttrName".
+ private void checkComplexAttributeChildren(AttributeInterface attribute, Lang lang, String namePrefix) {
+ if (attribute instanceof AbstractComplexAttribute && !(attribute instanceof ListAttributeInterface)) {
+ for (AttributeInterface child : ((AbstractComplexAttribute) attribute).getAttributes()) {
+ this.checkNestedAttribute(child, lang, namePrefix);
+ }
+ }
+ }
+
+ private void checkNestedAttribute(AttributeInterface attribute, Lang lang, String namePrefix) {
+ attribute.setRenderingLang(lang.getCode());
+ String path = SolrComplexAttributes.appendPath(namePrefix, attribute.getName());
+ if (!attribute.isSimple()) {
+ this.checkComplexAttributeChildren(attribute, lang, path);
+ return;
+ }
+ if (SolrComplexAttributes.isIndexableCompositeBoolean(attribute)) {
+ String fieldName = this.buildFieldName(lang, path);
+ // Single-valued: a Composite occurs at most once per document per lang (Monolist and
+ // Monolist-of-Composite are excluded above), so the nested field is never repeated.
+ this.checkField(fieldName, SolrComplexAttributes.solrType(attribute), false);
+ }
+ }
+
private void checkField(String fieldName, String type) {
this.checkField(fieldName, type, false);
}
diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java
index 1cc685429c..c4d17317e5 100644
--- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java
+++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java
@@ -19,7 +19,9 @@
import com.agiletec.aps.system.common.entity.event.EntityTypesChangingObserver;
import com.agiletec.aps.system.common.entity.model.IApsEntity;
import com.agiletec.aps.system.common.entity.model.SmallEntityType;
+import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute;
import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface;
+import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface;
import com.agiletec.aps.system.services.lang.ILangManager;
import com.agiletec.aps.system.services.lang.Lang;
import com.agiletec.aps.util.ApsTenantApplicationUtils;
@@ -173,17 +175,15 @@ private List getContentTypesSettings(ISolrSchemaDAO schemaD
entityType.getDescription());
list.add(typeSettings);
Content prototype = this.getContentManager().createContentType(entityType.getCode());
+ List languages = this.langManager.getLangs();
for (AttributeInterface attribute : prototype.getAttributeList()) {
- Map> currentConfig = new HashMap<>();
- List languages = this.langManager.getLangs();
- for (Lang lang : languages) {
- String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName();
- fields.stream()
- .filter(f -> f.get(SOLR_FIELD_NAME).equals(fieldName))
- .findFirst().ifPresent(currentField ->
- currentConfig.put(fieldName, (Map) currentField));
- }
+ Map> currentConfig =
+ this.buildCurrentFieldConfig(attribute.getName(), languages, fields);
typeSettings.addAttribute(attribute, currentConfig, languages);
+ if (!attribute.isSimple()) {
+ this.collectNestedBooleanAttributes(attribute, attribute.getName(), fields, languages,
+ typeSettings);
+ }
}
}
} catch (Exception e) {
@@ -192,6 +192,42 @@ private List getContentTypesSettings(ISolrSchemaDAO schemaD
return list;
}
+ private Map> buildCurrentFieldConfig(String attributeName,
+ List languages, List
-
+
-
+
_booleanFieldName
+ <%-- ThreeState: Any (no filter), Yes, No, Not set (the unset/none state). --%>
-
" class="normal" >
+
" class="normal" >
+
" class="normal" >
+
" class="normal">
+
" class="normal">
+
+
+
+
+
+
+ _booleanFieldName
+
+
+
" class="normal" >
" class="normal" >
" class="normal">
diff --git a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/modules/include/threeStateAttributeInputField.jsp b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/modules/include/threeStateAttributeInputField.jsp
index d95d03e64c..d8e9fffd24 100644
--- a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/modules/include/threeStateAttributeInputField.jsp
+++ b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/modules/include/threeStateAttributeInputField.jsp
@@ -24,6 +24,6 @@
id="none_%{#currentThreestateAttributeNameVar}"
value=""
checked="%{#attribute.booleanValue == null}"/>
-
+
\ No newline at end of file
diff --git a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/user/user-list.jsp b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/user/user-list.jsp
index 8c391c3110..e5fb26a44c 100644
--- a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/user/user-list.jsp
+++ b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/user/user-list.jsp
@@ -241,16 +241,56 @@
- <%-- Boolean & ThreeState --%>
+ <%-- ThreeState: Any, Yes, No, Not set (the unset/none state) --%>
+
+
+
+
+
+ _booleanFieldName
+
+
+
+ <%-- ThreeState has three stored states plus "don't filter": Any (no
+ filter), Yes (true), No (false) and Not set (the unset/none state). --%>
+