From ab2b02ac8d4dfe32d7f7e0bd6428aa27f494343b Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 6 Aug 2026 19:58:44 +0200 Subject: [PATCH 1/2] ESB-1133 SOLR boolean index --- README.md | 155 +- .../apsadmin/global-messages_en.properties | 4 + .../apsadmin/global-messages_it.properties | 4 + .../entity/AbstractApsEntityFinderAction.java | 88 +- .../system/entity/EntityActionHelper.java | 133 +- ...stractBaseEntityAttributeConfigAction.java | 25 +- .../type/CompositeAttributeConfigAction.java | 16 + .../entity/type/EntityTypeConfigAction.java | 53 +- .../system/entity/type/package_en.properties | 3 + .../system/entity/type/package_it.properties | 3 + ...attribute-type-entry-composite-element.jsp | 17 + .../entity/attribute-type-entry-composite.jsp | 23 +- .../apsadmin/jsp/entity/entityFinding.jsp | 32 +- .../entity/include/attribute-flag-cell.jsp | 31 + .../jsp/entity/include/attribute-list.jsp | 93 +- .../include/threeStateAttributeInputField.jsp | 2 +- .../WEB-INF/apsadmin/jsp/user/user-list.jsp | 98 +- .../EntityActionHelperNestedSearchTest.java | 183 +++ .../CompositeAttributeConfigActionTest.java | 175 ++- .../type/EntityTypeConfigActionTest.java | 95 +- advcontentsearch.postman_collection.json | 1365 +++++++++++++++++ .../services/content/ContentManager.java | 6 +- .../content/widget/UserFilterOptionBean.java | 16 +- .../services/resource/ResourceManager.java | 11 + .../services/searchengine/IndexerDAO.java | 42 +- .../liquibase/jacms/changeSetPort.xml | 4 + .../port/00000000000001_dataPort_test.xml | 84 + ...0803000000_jacms_widen_search_attrname.xml | 29 + ...00_jacms_boolean_userfilter_any_option.xml | 78 + .../port/clob/production/guifragment_12_2.ftl | 24 + .../port/clob/production/guifragment_14_2.ftl | 24 + .../port/clob/production/guifragment_15_2.ftl | 24 + .../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 + .../inc/userFilter-module-entity-Boolean.jsp | 20 +- .../inc/userFilter-module-entity-CheckBox.jsp | 26 +- .../userFilter-module-entity-ThreeState.jsp | 23 +- .../apsadmin/jsp/content/contentFinding.jsp | 138 +- .../contentListViewer/entryFilter.jsp | 41 +- .../services/content/ContentManagerTest.java | 23 +- .../services/content/TestContentManager.java | 241 ++- .../entity/TestContentEntityManager.java | 2 +- .../widget/UserFilterOptionBeanTest.java | 56 +- .../resource/ResourceManagerTest.java | 41 +- .../services/searchengine/IndexerDAOTest.java | 198 +++ .../content/TestContentFinderAction.java | 133 +- .../attribute/TestContentLinkAction.java | 8 +- .../TestHypertextAttributeAction.java | 8 +- .../viewer/TestContentFinderViewerAction.java | 8 +- .../entity/TestJacmsEntityManagersAction.java | 8 +- .../ContentControllerIntegrationTest.java | 109 +- .../ContentTypeResourceIntegrationTest.java | 146 ++ .../content/TestContentFinderAction.java | 4 +- .../content/TestIntroNewContentAction.java | 2 +- .../common/entity/AbstractEntityDAO.java | 57 +- .../common/entity/ApsEntityManager.java | 141 +- .../system/common/entity/IEntityManager.java | 29 + .../entity/model/EntitySearchFilter.java | 11 + .../model/attribute/AttributeInterface.java | 69 + .../model/attribute/BooleanAttribute.java | 28 + .../model/attribute/CompositeAttribute.java | 8 +- .../entity/model/attribute/DateAttribute.java | 13 + .../model/attribute/NumberAttribute.java | 17 + .../model/attribute/ThreeStateAttribute.java | 30 + .../entity/search/EntitySearchKeys.java | 92 ++ .../entity/search/EntitySearchSchema.java | 113 ++ .../entity/search/NestedSearchSupport.java | 499 ++++++ .../common/entity/search/SearchFieldType.java | 59 + .../entity/search/SearchRecordSpec.java | 27 + .../entity/search/SearchableAttributeRef.java | 92 ++ .../entity/AbstractEntityTypeService.java | 90 ++ .../AbstractEntityTypeValidator.java | 11 + .../resources/liquibase/changeSetServ.xml | 2 + .../serv/00000000000005_schemaServ.xml | 23 + .../main/resources/rest/messages.properties | 6 + .../AbstractEntityDAONestedSearchTest.java | 353 +++++ .../ApsEntityManagerNestedSearchableTest.java | 205 +++ .../EntitySearchFilterNestedSearchTest.java | 114 ++ .../attribute/AttributeSearchFieldTest.java | 164 ++ .../CompositeAttributeXmlConfigTest.java | 137 ++ .../entity/search/EntitySearchKeysTest.java | 217 +++ .../entity/search/EntitySearchSchemaTest.java | 151 ++ .../search/NestedSearchSupportTest.java | 845 ++++++++++ .../search/SearchableAttributeRefTest.java | 123 ++ .../5_POST_composite_listFilter_invalid.json | 28 + .../5_POST_composite_listFilter_valid.json | 37 + .../ProfileTypeControllerIntegrationTest.java | 58 + .../servlet/security/BasicAuthFilterTest.java | 20 +- run-reactor-tests.sh | 256 ++++ .../content/AdvContentFacetManager.java | 52 +- .../jpsolr/aps/system/solr/IndexerDAO.java | 107 +- .../jpsolr/aps/system/solr/SearcherDAO.java | 13 +- .../aps/system/solr/SolrFieldsChecker.java | 53 +- .../system/solr/SolrSearchEngineManager.java | 50 +- .../solr/model/ContentTypeSettings.java | 61 +- .../aps/system/solr/model/SolrFields.java | 34 + .../content/AdvContentFacetManagerTest.java | 95 +- .../aps/system/solr/IndexerDAOTest.java | 346 +++++ .../aps/system/solr/SearcherDAOTest.java | 164 +- .../system/solr/SolrFieldsCheckerTest.java | 233 ++- .../solr/SolrSearchEngineManagerTest.java | 244 ++- .../solr/model/ContentTypeSettingsTest.java | 235 +++ .../AdvContentSearchControllerTest.java | 4 +- .../jpwebdynamicform/changeSetServ.xml | 2 + ...00003_schemaServ_widen_search_attrname.xml | 31 + 108 files changed, 9577 insertions(+), 564 deletions(-) create mode 100644 admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/include/attribute-flag-cell.jsp create mode 100644 admin-console/src/test/java/com/agiletec/apsadmin/system/entity/EntityActionHelperNestedSearchTest.java create mode 100644 advcontentsearch.postman_collection.json create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/20260803000000_jacms_widen_search_attrname.xml create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/20260807000000_jacms_boolean_userfilter_any_option.xml create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_12_2.ftl create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_14_2.ftl create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_15_2.ftl 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 cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/searchengine/IndexerDAOTest.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/EntitySearchKeys.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/EntitySearchSchema.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/NestedSearchSupport.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/SearchFieldType.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/SearchRecordSpec.java create mode 100644 engine/src/main/java/org/entando/entando/aps/system/common/entity/search/SearchableAttributeRef.java create mode 100644 engine/src/main/resources/liquibase/serv/00000000000005_schemaServ.xml create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedSearchTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/ApsEntityManagerNestedSearchableTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedSearchTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/AttributeSearchFieldTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttributeXmlConfigTest.java create mode 100644 engine/src/test/java/org/entando/entando/aps/system/common/entity/search/EntitySearchKeysTest.java create mode 100644 engine/src/test/java/org/entando/entando/aps/system/common/entity/search/EntitySearchSchemaTest.java create mode 100644 engine/src/test/java/org/entando/entando/aps/system/common/entity/search/NestedSearchSupportTest.java create mode 100644 engine/src/test/java/org/entando/entando/aps/system/common/entity/search/SearchableAttributeRefTest.java create mode 100644 engine/src/test/java/org/entando/entando/web/userprofile/5_POST_composite_listFilter_invalid.json create mode 100644 engine/src/test/java/org/entando/entando/web/userprofile/5_POST_composite_listFilter_valid.json create mode 100755 run-reactor-tests.sh create mode 100644 solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java create mode 100644 webdynamicform-plugin/src/main/resources/liquibase/jpwebdynamicform/serv/00000000000003_schemaServ_widen_search_attrname.xml diff --git a/README.md b/README.md index e7113c6878..51b30600d0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ The Content Scheduler, Content Workflow, and Web Dynamic Form plugins are disabl ## Testing +The test suite runs under the `pre-deployment-verification` Maven profile — without it, surefire +is skipped and no tests execute. + +### Quick commands + To execute all the tests: ``` @@ -32,41 +37,119 @@ 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. +### Reactor test runner (`run-reactor-tests.sh`) + +To run the tests of one or more reactor modules, the repo ships a helper at the project root that +wraps the Maven invocation, chooses the right build strategy, and prints a per-module PASS/FAIL +summary (logs are saved under `test-results/`). + +``` +# interactive module picker (UP/DOWN move, SPACE select, a=all, n=none, ENTER confirm, q quit) +./run-reactor-tests.sh + +# non-interactive: test only the given module(s) +./run-reactor-tests.sh engine cms-plugin + +# test every module, no prompt +ASSUME_YES=1 ./run-reactor-tests.sh +``` + +When a **subset** of modules is selected, the script first builds the selected modules and their +upstream dependencies **without** tests (`install -DskipTests`), then runs the tests for the +selected modules only (no `-am`, so dependencies are not re-tested). Selecting all modules runs the +whole reactor in a single pass. + +Environment overrides: + +| Variable | Default | Effect | +| :-- | :-- | :-- | +| `PROFILE` | `pre-deployment-verification` | Maven profile that enables the tests | +| `MVN_OPTS` | _(empty)_ | extra Maven options, e.g. `-o` for offline | +| `ASSUME_YES` | `0` | skip the picker and test every module | +| `DRY_RUN` | `0` | print the Maven command(s) without running them | + +In a non-interactive shell (CI or a pipe) the picker is skipped automatically and all modules are +tested. + +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 | -| :-- | :-- | :-- | :---------------- | -|CDS | CDS_ENABLED | true, [false] | Enable Content Delivery Server | -|| CDS_PUBLIC_URL | http://YOUR-APP-NAME-cds.YOUR-HOST-NAME/YOUR-TENANT-ID | | -|| CDS_PRIVATE_URL | http://YOUR-TENANT-ID-cds-service:8080 | | -|| CDS_PATH | /api/v1 || -| Keycloak/TLS | KEYCLOAK_AUTH_URL | https://YOUR-HOST-NAME/auth| -|| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI| https://YOUR-HOST-NAME/auth/realms/entando || -|| ENTANDO_APP_USE_TLS | | protocol for the redirect to keycloak login | -|| ENTANDO_APP_ENGINE_EXTERNAL_PORT | | to force the port to use | -|Redis server | REDIS_ACTIVE | true, [false] | to activate Redis cache management | -|| REDIS_ADDRESS |URL [redis://localhost:6379]| Redis host address|| -|| REDIS_ADDRESSES | | for HA, insert the comma separated list of nodes | -|| REDIS_MASTER_NAME | [mymaster] | To specify the name of the master node | -|| REDIS_SESSION_ACTIVE | true, [false] | enable storing of HTTP sessions in the Redis cluster, REDIS_ACTIVE has to be "true" too. -|| REDIS_PASSWORD | | | -|| REDIS_USE_SENTINEL_EVENTS | [true], false | when Redis is active and Redis addresses is set, use Sentinel Monitoring -|| REDIS_IO_THREAD_POOL_SIZE | Integer, [8] | to mitigate errors caused by missing front-end cache refresh -|Solr | SOLR_ACTIVE | true, false | to activate Solr search -|| SOLR_ADDRESS |[http://localhost:8983/solr] | Solr host address -|| SOLR_CORE | string, [entando] | name of collection -|| advancedSearch | true, false | To add the Solr config page to the CMS menu -| Tomcat server | AGENT_ENABLED | true, [false] | if true, adds the agent options to tomcat -|| AGENT_OPTS | javaagent:~/YOUR-JARFILE.jar, [empty] | the jar file with the agent options to use -|| TOMCAT_MAX_POST_SIZE | Enter a value in bytes, [209,715,200 bytes] | to configure connector maxPostSize | -|| FILE_UPLOAD_MAX_SIZE | Enter a value in bytes, [52,428,800 bytes] | to configure the application upload limit -| MISC | | | | -|| ENTANDO_BUNDLE_CLI_ETC | ${ENTANDO_BUNDLE_CLI_ETC}/hub/credentials | Credentials/parameters saved within JSON files under this path for ent bundle add hub command -|| ENTANDO_APP_ENGINE_HEALTH_CHECK_TYPE | db.migration.strategy | [auto], skip, disabled, generate_sql | Liquibase strategy -|| LOG_CONFIG_FILE_PATH | | to use the logback composable feature | -|| ENTANDO_DOCKER_REGISTRY_OVERRIDE | | Deprecated-for v1 bundles, to propagate to CM for plugins | -| Feature Flags | ENTANDO_FEATURE_FLAGS | comma-separated list of tags | Enable experimental features. Example: `CACHE_PIPELINE,HEADLESS_WIDGET_CONFIG` | -|| | | Available flags: `CACHE_PIPELINE`, `HEADLESS_WIDGET_CONFIG` | -|| ENTANDO_FF_DEEP_DEBUG | comma-separated list of tags | Enable deep debug logging for specific components. Example: `service-reload` | +| Group | Name | Value [default] | Description | +|:--------------|:-------------------------------------------------------|:-------------------------------------------------------|:----------------------------------------------------------------------------------------------| +| CDS | CDS_ENABLED | true, [false] | Enable Content Delivery Server | +| | CDS_PUBLIC_URL | http://YOUR-APP-NAME-cds.YOUR-HOST-NAME/YOUR-TENANT-ID | | +| | CDS_PRIVATE_URL | http://YOUR-TENANT-ID-cds-service:8080 | | +| | CDS_PATH | /api/v1 | | +| Keycloak/TLS | KEYCLOAK_AUTH_URL | https://YOUR-HOST-NAME/auth | | +| | SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI | https://YOUR-HOST-NAME/auth/realms/entando | | +| | ENTANDO_APP_USE_TLS | | protocol for the redirect to keycloak login | +| | ENTANDO_APP_ENGINE_EXTERNAL_PORT | | to force the port to use | +| Redis server | REDIS_ACTIVE | true, [false] | to activate Redis cache management | +| | REDIS_ADDRESS | URL [redis://localhost:6379] | Redis host address || +| | REDIS_ADDRESSES | | for HA, insert the comma separated list of nodes | +| | REDIS_MASTER_NAME | [mymaster] | To specify the name of the master node | +| | REDIS_SESSION_ACTIVE | true, [false] | enable storing of HTTP sessions in the Redis cluster, REDIS_ACTIVE has to be "true" too. | +| | REDIS_PASSWORD | | | +| | REDIS_USE_SENTINEL_EVENTS | [true], false | when Redis is active and Redis addresses is set, use Sentinel Monitoring | +| | REDIS_IO_THREAD_POOL_SIZE | Integer, [8] | to mitigate errors caused by missing front-end cache refresh | +| Solr | SOLR_ACTIVE | true, false | to activate Solr search | +| | SOLR_ADDRESS | [http://localhost:8983/solr] | Solr host address | +| | SOLR_CORE | string, [entando] | name of collection | +| | advancedSearch | true, false | To add the Solr config page to the CMS menu | +| Tomcat server | AGENT_ENABLED | true, [false] | if true, adds the agent options to tomcat | +| | AGENT_OPTS | javaagent:~/YOUR-JARFILE.jar, [empty] | the jar file with the agent options to use | +| | TOMCAT_MAX_POST_SIZE | Enter a value in bytes, [209,715,200 bytes] | to configure connector maxPostSize | +| | FILE_UPLOAD_MAX_SIZE | Enter a value in bytes, [52,428,800 bytes] | to configure the application upload limit | +| MISC | | | | +| | ENTANDO_BUNDLE_CLI_ETC | ${ENTANDO_BUNDLE_CLI_ETC}/hub/credentials | Credentials/parameters saved within JSON files under this path for ent bundle add hub command | +| | ENTANDO_APP_ENGINE_HEALTH_CHECK_TYPE | db.migration.strategy | [auto], skip, disabled, generate_sql | Liquibase strategy +| | LOG_CONFIG_FILE_PATH | | to use the logback composable feature | +| | ENTANDO_DOCKER_REGISTRY_OVERRIDE | | Deprecated-for v1 bundles, to propagate to CM for plugins | +| Feature Flags | ENTANDO_FEATURE_FLAGS | comma-separated list of tags | Enable experimental features. Example: `CACHE_PIPELINE,HEADLESS_WIDGET_CONFIG` | +| | | | Available flags: `CACHE_PIPELINE`, `HEADLESS_WIDGET_CONFIG` | +| | ENTANDO_FF_DEEP_DEBUG | comma-separated list of tags | Enable deep debug logging for specific components. Example: `service-reload` | diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_en.properties b/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_en.properties index f0023ab965..a8055ae3a9 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_en.properties +++ b/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_en.properties @@ -95,6 +95,8 @@ label.no=No label.true=True label.false=False label.bothYesAndNo=Both +label.any=Any +label.notSet=Not set label.state=Status label.all=All label.confirm=Confirm @@ -319,6 +321,8 @@ Entity.attribute.flag.mandatory.full=Mandatory Entity.attribute.flag.mandatory.short=* Entity.attribute.flag.searchable.full=Can be used as a filter in lists Entity.attribute.flag.searchable.short=F +Entity.attribute.flag.searchable.notApplicable.type=Not available for this attribute type +Entity.attribute.flag.searchable.notApplicable.list=Not available for attributes inside a list #deprecated - start Entity.attribute.flag.searcheable.full=Can be used as a filter in lists Entity.attribute.flag.searcheable.short=F diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_it.properties b/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_it.properties index eb02d85b33..4a8c49af13 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_it.properties +++ b/admin-console/src/main/java/com/agiletec/apsadmin/global-messages_it.properties @@ -102,6 +102,8 @@ label.no=No label.true=Vero label.false=Falso label.bothYesAndNo=Indifferente +label.any=Qualsiasi +label.notSet=Non impostato label.state=Stato label.all=Tutti label.confirm=Conferma @@ -328,6 +330,8 @@ Entity.attribute.flag.mandatory.full=Obbligatorio Entity.attribute.flag.mandatory.short=* Entity.attribute.flag.searchable.full=Utilizzabile come filtro nelle liste Entity.attribute.flag.searchable.short=F +Entity.attribute.flag.searchable.notApplicable.type=Non disponibile per questo tipo di attributo +Entity.attribute.flag.searchable.notApplicable.list=Non disponibile per gli attributi dentro una lista #deprecated labels - start Entity.attribute.flag.searcheable.full=Utilizzabile come filtro nelle liste Entity.attribute.flag.searcheable.short=F diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/AbstractApsEntityFinderAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/AbstractApsEntityFinderAction.java index 54c9992b5e..5bd4db7fd9 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/AbstractApsEntityFinderAction.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/AbstractApsEntityFinderAction.java @@ -23,7 +23,10 @@ import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.aps.system.common.entity.search.EntitySearchKeys; +import org.entando.entando.aps.system.common.entity.search.EntitySearchSchema; import com.agiletec.aps.system.common.entity.IEntityManager; +import org.entando.entando.aps.system.common.entity.search.SearchableAttributeRef; import com.agiletec.aps.system.common.entity.model.EntitySearchFilter; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; @@ -175,27 +178,98 @@ public String getSearchFormFieldValue(String inputFieldName) { return val; } + /** + * @return the same list as {@link #getSearchableAttributes()}. + * @deprecated the name is misspelled; use {@link #getSearchableAttributes()}. Not removable yet: + * {@code webdynamicform-plugin}'s {@code messageFinding.jsp} still binds to + * {@code searcheableAttributes}, and no JSP is compiled by this build, so deleting this would break + * that page silently. Retire it together with that binding. + */ @Deprecated public List getSearcheableAttributes() { return this.getSearchableAttributes(); } - + + /** + * The searchable top-level attributes, as real {@link AttributeInterface} instances. + * + *

This deliberately keeps its historical return type. It is a {@code public} method on a + * {@code public abstract} class that downstream projects extend, and its elements are addressed by + * custom JSPs through arbitrary attribute properties ({@code #attribute.items}, + * {@code #attribute.roles}, ...). Narrowing it to a projection would compile cleanly here and then + * fail at runtime in customer code - silently in JSPs, which this build never compiles.

+ * + *

Nested searchable attributes are not included here, because they cannot be represented + * as a plain attribute: their form field is named after a path key, not after + * {@code attribute.getName()}. Search forms that support them must iterate + * {@link #getSearchableAttributeRefs()} instead.

+ * + * @return the ordered list of searchable top-level attributes; never null. + */ public List getSearchableAttributes() { - List searchableAttributes = new ArrayList(); + List searchableAttributes = new ArrayList<>(); IApsEntity prototype = this.getEntityPrototype(); if (null == prototype) { return searchableAttributes; } - List contentAttributes = prototype.getAttributeList(); - for (int i=0; i_}. + * + *

Each entry is a {@link SearchableAttributeRef} - the key, the display label and the real + * attribute - not a renamed copy of the attribute. OGNL resolves {@code #attribute.name} to the key, + * {@code #attribute.type} and {@code #attribute.textAttribute} to the real attribute's own values; + * anything else is reached through {@code #attribute.source.}.

+ * + *

This is additive: {@link #getSearchableAttributes()} keeps its original contract for + * pre-existing callers, and only forms that need nested attributes bind to this one.

+ * + * @return the ordered list of searchable attribute references; never null. + */ + public List getSearchableAttributeRefs() { + return this.getSearchSchema().getSearchableAttributes(); + } + + /** + * Display labels for {@link #getSearchableAttributeRefs()}, keyed by the attribute's machine key. + * A nested boolean's label is its hierarchy (e.g. {@code "compo > cmp_bool"}) reconstructed from the + * real attribute tree, so the search form renders it verbatim instead of splitting the flattened key + * on '_' - which would mis-segment a name that itself contains '_'. + * @return a map from machine key to display label; never null. + * @deprecated a form iterating {@link #getSearchableAttributeRefs()} already holds the label: + * {@code #attribute.label}. The core finder JSPs no longer bind this. Not removable yet: a downstream + * JSP may still bind {@code searchableAttributeLabels}, and no JSP is compiled by this build, so + * deleting it would break that page silently. + */ + @Deprecated + public Map getSearchableAttributeLabels() { + return this.getSearchSchema().getLabels(); + } + + /** + * What the current entity type offers to a search: the attributes, their labels and the key each is + * addressed by. Read from the entity manager, which computes it once per type and keeps it, so a + * form render no longer walks the attribute tree once per question it asks. + * @return the schema of the current type; never null. + */ + protected EntitySearchSchema getSearchSchema() { + IEntityManager entityManager = this.getEntityManager(); + EntitySearchSchema schema = (null == entityManager) + ? null : entityManager.getSearchSchema(this.getEntityTypeCode()); + // a finder without a manager has nothing to offer, and must not throw while a form renders + return (null != schema) ? schema + : EntitySearchSchema.build(null, EntitySearchKeys.DEFAULT_MAX_KEY_LENGTH); + } + public List getAttributeRoles() { return this.getEntityManager().getAttributeRoles(); } @@ -246,4 +320,4 @@ protected void setFilters(EntitySearchFilter[] filters) { private EntitySearchFilter[] _filters = new EntitySearchFilter[0]; -} \ No newline at end of file +} diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/EntityActionHelper.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/EntityActionHelper.java index 86f781e2dc..12393d65ff 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/EntityActionHelper.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/EntityActionHelper.java @@ -25,6 +25,8 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; +import org.entando.entando.aps.system.common.entity.search.NestedSearchSupport; +import org.entando.entando.aps.system.common.entity.search.SearchableAttributeRef; import com.agiletec.aps.system.common.entity.model.ApsEntity; import com.agiletec.aps.system.common.entity.model.AttributeFieldError; import com.agiletec.aps.system.common.entity.model.AttributeTracer; @@ -33,10 +35,8 @@ import com.agiletec.aps.system.common.entity.model.attribute.AbstractAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.AttributeRole; -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.ITextAttribute; -import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; +import org.entando.entando.aps.system.common.entity.search.SearchFieldType; import com.agiletec.aps.util.CheckFormatUtil; import com.agiletec.aps.util.DateConverter; import com.agiletec.apsadmin.system.BaseActionHelper; @@ -49,10 +49,15 @@ * classes which handle elements built with the "ApsEntity' entries. * @author E.Santoboni */ +// NOTE: java:S2143 ("use the java.time API") is intentionally suppressed. Legacy java.util.Date is used +// only to parse the date-range search form fields (via DateConverter); java.time migration is out of +// scope for ESB-1133 (boolean search) and tracked separately. +@SuppressWarnings("java:S2143") public class EntityActionHelper extends BaseActionHelper implements IEntityActionHelper, BeanFactoryAware { private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(EntityActionHelper.class); - + + @Override public void updateEntity(IApsEntity currentEntity, HttpServletRequest request) { try { @@ -192,36 +197,53 @@ public EntitySearchFilter[] getAttributeFilters(AbstractApsEntityFinderAction en if (null == prototype) { return filters; } - List contentAttributes = prototype.getAttributeList(); - for (int i = 0; i < contentAttributes.size(); i++) { - AttributeInterface attribute = contentAttributes.get(i); - if (attribute.isActive() && attribute.isSearchable()) { - if (attribute instanceof ITextAttribute) { - String insertedText = entityFinderAction.getSearchFormFieldValue(attribute.getName() + "_textFieldName"); - if (null != insertedText && insertedText.trim().length() > 0) { - EntitySearchFilter filterToAdd = new EntitySearchFilter(attribute.getName(), true, insertedText.trim(), true); - filters = this.addFilter(filters, filterToAdd); - } - } else if (attribute instanceof DateAttribute) { - Date dateStart = this.getDateSearchFormValue(entityFinderAction, attribute.getName(), "_dateStartFieldName", true); - Date dateEnd = this.getDateSearchFormValue(entityFinderAction, attribute.getName(), "_dateEndFieldName", false); - if (null != dateStart || null != dateEnd) { - EntitySearchFilter filterToAdd = new EntitySearchFilter(attribute.getName(), true, dateStart, dateEnd); - filters = this.addFilter(filters, filterToAdd); - } - } else if (attribute instanceof BooleanAttribute) { - String booleanValue = entityFinderAction.getSearchFormFieldValue(attribute.getName() + "_booleanFieldName"); - if (null != booleanValue && booleanValue.trim().length() > 0) { - EntitySearchFilter filterToAdd = new EntitySearchFilter(attribute.getName(), true, booleanValue, false); - filters = this.addFilter(filters, filterToAdd); - } - } else if (attribute instanceof NumberAttribute) { - BigDecimal numberStart = this.getNumberSearchFormValue(entityFinderAction, attribute.getName(), "_numberStartFieldName", true); - BigDecimal numberEnd = this.getNumberSearchFormValue(entityFinderAction, attribute.getName(), "_numberEndFieldName", false); - if (null != numberStart || null != numberEnd) { - EntitySearchFilter filterToAdd = new EntitySearchFilter(attribute.getName(), true, numberStart, numberEnd); - filters = this.addFilter(filters, filterToAdd); - } + // Same list the search form is built from: searchable top-level attributes plus Composite-nested + // boolean-like attributes keyed by "_". Iterating the identical list + // guarantees the parser resolves exactly the field names the form submitted. The eligibility + // gate (active/searchable, boolean-like when nested) is applied once, by collectSearchable; the + // dispatch below reads the REAL attribute, so the type is always the genuine one. + List searchableAttributes = NestedSearchSupport + .collectSearchable(prototype); + for (SearchableAttributeRef ref : searchableAttributes) { + String key = ref.key(); + // One dispatch mechanism, the same one the finder JSPs use: the attribute's declared search + // field type, except for text. TEXT and isTextAttribute() happen to coincide for every type + // the platform ships, but they are different questions - TEXT is "indexable as free text", + // isTextAttribute() is "carries a per-language text a filter can match" - so the text branch + // keeps asking the narrower one it has always asked. + if (ref.isTextAttribute()) { + String insertedText = entityFinderAction.getSearchFormFieldValue(key + "_textFieldName"); + if (null != insertedText && insertedText.trim().length() > 0) { + EntitySearchFilter filterToAdd = new EntitySearchFilter(key, true, insertedText.trim(), true); + filters = this.addFilter(filters, filterToAdd); + } + } else if (ref.isDate()) { + Date dateStart = this.getDateSearchFormValue(entityFinderAction, key, "_dateStartFieldName", true); + Date dateEnd = this.getDateSearchFormValue(entityFinderAction, key, "_dateEndFieldName", false); + if (null != dateStart || null != dateEnd) { + EntitySearchFilter filterToAdd = new EntitySearchFilter(key, true, dateStart, dateEnd); + filters = this.addFilter(filters, filterToAdd); + } + } else if (ref.isTristate()) { + // Three states: "true"/"false" filter by value; the "not set" literal matches the unset + // state, which on the DB search path is the ABSENCE of a record (a ThreeState writes no + // row when unset) - so it is queried via the null option, not a value; blank means "Any". + EntitySearchFilter filterToAdd = this.buildThreeStateFilter(entityFinderAction, key); + if (null != filterToAdd) { + filters = this.addFilter(filters, filterToAdd); + } + } else if (ref.isBooleanLike()) { + String booleanValue = entityFinderAction.getSearchFormFieldValue(key + "_booleanFieldName"); + if (null != booleanValue && booleanValue.trim().length() > 0) { + EntitySearchFilter filterToAdd = new EntitySearchFilter(key, true, booleanValue, false); + filters = this.addFilter(filters, filterToAdd); + } + } else if (ref.isNumber()) { + BigDecimal numberStart = this.getNumberSearchFormValue(entityFinderAction, key, "_numberStartFieldName", true); + BigDecimal numberEnd = this.getNumberSearchFormValue(entityFinderAction, key, "_numberEndFieldName", false); + if (null != numberStart || null != numberEnd) { + EntitySearchFilter filterToAdd = new EntitySearchFilter(key, true, numberStart, numberEnd); + filters = this.addFilter(filters, filterToAdd); } } } @@ -231,18 +253,51 @@ public EntitySearchFilter[] getAttributeFilters(AbstractApsEntityFinderAction en @Override public String[] getAttributeFilterFieldName(ApsEntity prototype, String attrName) { AbstractAttribute attr = (AbstractAttribute) prototype.getAttribute(attrName); + if (null == attr) { + // Not a top-level attribute: it may be a Composite-nested boolean addressed by its + // path key "_". Resolve it so the remembered search round-trips. + attr = (AbstractAttribute) NestedSearchSupport.resolveNestedByKey(prototype, attrName); + } + if (null == attr) { + return new String[0]; + } + // Same dispatch as getAttributeFilters, so the field names derived here are the ones parsed there. + // Null-safe on purpose: a searchable Composite/Monolist declares no search field type. + SearchFieldType searchFieldType = attr.getSearchFieldType(); if (attr.isTextAttribute()) { return new String[] {attrName + "_textFieldName"}; - } else if (attr instanceof DateAttribute) { + } else if (SearchFieldType.DATE == searchFieldType) { return new String[] {attrName + "_dateStartFieldName", attrName + "_dateEndFieldName"}; - } else if (attr instanceof NumberAttribute) { + } else if (SearchFieldType.NUMBER == searchFieldType) { return new String[] {attrName + "_numberStartFieldName", attrName + "_numberEndFieldName"}; - } else if (attr instanceof BooleanAttribute) { + } else if (null != searchFieldType && searchFieldType.isBooleanFamily()) { + // The whole boolean family shares one form field; ThreeState differs only in the values it + // offers, which buildThreeStateFilter handles. return new String[] {attrName + "_booleanFieldName"}; } - return null; + return new String[0]; } + /** + * Build the search filter for a ThreeState attribute from its {@code _booleanFieldName} form field. + * Blank -> {@code null} ("Any", no filter). {@code "none"} ("Not set") -> a null-option filter, + * because an unset ThreeState leaves no DB search record. {@code "true"}/{@code "false"} -> a value + * filter, as for a plain boolean. + */ + private EntitySearchFilter buildThreeStateFilter(AbstractApsEntityFinderAction entityFinderAction, String attrName) { + String value = entityFinderAction.getSearchFormFieldValue(attrName + "_booleanFieldName"); + if (null == value || value.trim().isEmpty()) { + return null; + } + value = value.trim(); + if (ThreeStateAttribute.NOT_SET_SEARCH_VALUE.equalsIgnoreCase(value)) { + EntitySearchFilter filter = new EntitySearchFilter(attrName, true); + filter.setNullOption(true); + return filter; + } + return new EntitySearchFilter(attrName, true, value, false); + } + private Date getDateSearchFormValue(AbstractApsEntityFinderAction entityFinderAction, String fieldName, String dateFieldNameSuffix, boolean start) { String inputFormName = fieldName + dateFieldNameSuffix; 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..d401d43b26 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 org.entando.entando.aps.system.common.entity.search.NestedSearchSupport; 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; @@ -47,6 +48,10 @@ * Base action for Configure Entity Attributes. * @author E.Santoboni */ +// NOTE: java:S2143 ("use the java.time API") is intentionally suppressed. This date-range attribute +// config action is inherently built on java.util.Date (range start/end/equal fields); migrating it to +// java.time is out of scope for ESB-1133 (boolean search) and tracked separately. +@SuppressWarnings("java:S2143") public class AbstractBaseEntityAttributeConfigAction extends BaseAction implements BeanFactoryAware { private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(AbstractBaseEntityAttributeConfigAction.class); @@ -266,6 +271,24 @@ public boolean isSearchableOptionSupported(String attributeTypeCode) { } return false; } + + /** + * Whether the given attribute type may be flagged searchable when used as a composite child. + * Only boolean-like children (Boolean, CheckBox, ThreeState) are indexed (under the path key + * "<composite>_<boolean>") in the DB search tables; every other type 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 boolean-like types. + */ + public boolean isNestedSearchableOptionSupported(String attributeTypeCode) { + try { + AttributeInterface attribute = this.getAttributePrototype(attributeTypeCode); + return NestedSearchSupport.isIndexableNested(attribute); + } catch (Exception t) { + _logger.error("error in isNestedSearchableOptionSupported", t); + } + return false; + } public AttributeInterface getAttributePrototype(String typeCode) { IEntityManager entityManager = this.getEntityManager(); @@ -660,4 +683,4 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { private BeanFactory _beanFactory; -} \ No newline at end of file +} diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigAction.java index 59ab858365..35b3a3b7c4 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigAction.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigAction.java @@ -120,6 +120,7 @@ public String saveAttributeElement() { AttributeInterface attribute = this.getAttributePrototype(this.getAttributeTypeCode()); attribute.setName(this.getAttributeName()); super.fillAttributeFields(attribute); + this.clearSearchableWithinList(attribute); composite.getAttributes().add(attribute); composite.getAttributeMap().put(attribute.getName(), attribute); } @@ -155,6 +156,21 @@ public String saveCompositeAttribute() { return SUCCESS; } + /** + * Force the {@code searchable} flag off when the Composite being edited is the nested type of a + * List/Monolist. A boolean reached through a list is never indexed as a per-attribute filter by + * either search engine, so the flag would be inert; the form does not offer it in that case, but + * this also covers a stale or forged submission. + * @param attribute the composite child being saved. + */ + private void clearSearchableWithinList(AttributeInterface attribute) { + if (null != this.getListAttribute() && attribute.isSearchable()) { + _logger.debug("Ignoring the searchable flag on '{}': the composite is nested in the list '{}'", + attribute.getName(), this.getListAttribute().getName()); + attribute.setSearchable(false); + } + } + public List getAllowedAttributeElementTypes() { List attributes = new ArrayList(); try { diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigAction.java index 23a079562f..cc6a58a24d 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigAction.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigAction.java @@ -24,6 +24,8 @@ import com.agiletec.aps.system.common.entity.IEntityManager; import com.agiletec.aps.system.common.entity.IEntityTypesConfigurer; +import org.entando.entando.aps.system.common.entity.search.EntitySearchKeys; +import org.entando.entando.aps.system.common.entity.search.NestedSearchSupport; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.apsadmin.system.ApsAdminSystemConstants; @@ -34,6 +36,9 @@ public class EntityTypeConfigAction extends AbstractEntityConfigAction implements IEntityTypeConfigAction { private static final EntLogger _logger = EntLogFactory.getSanitizedLogger(EntityTypeConfigAction.class); + + /** Form field the entry page renders every field error against (it lists them in one summary block). */ + private static final String ENTITY_TYPE_CODE_FIELD = "entityTypeCode"; @Override public void validate() { @@ -42,11 +47,53 @@ public void validate() { if (this.getOperationId() == ApsAdminSystemConstants.ADD && !this.hasFieldErrors()) { if (null != this.getEntityPrototype(entityType.getTypeCode())) { String[] args = {entityType.getTypeCode()}; - this.addFieldError("entityTypeCode", this.getText("error.entity.alredy.exists", args)); + this.addFieldError(ENTITY_TYPE_CODE_FIELD, this.getText("error.entity.alredy.exists", args)); + } + } + this.checkNestedSearchKeys(entityType); + } + + /** + * Report, as field errors, the nested boolean search keys the type would write and that the engine + * refuses to persist: keys produced by more than one attribute path, and keys longer than the DB + * column that has to store them. Without this the save would fail with a bare stack trace and the + * generic failure page; here the author is told which key is wrong and why, and is returned to the + * form (the {@code input} result of {@code saveEntityType}). + * @param entityType the entity type about to be saved. + */ + private void checkNestedSearchKeys(IApsEntity entityType) { + for (NestedSearchSupport.KeyProblem problem : NestedSearchSupport + .validateNestedSearchKeys(entityType, this.getMaxSearchKeyLength())) { + if (NestedSearchSupport.KeyProblemType.DUPLICATED == problem.type()) { + String[] args = {problem.key(), problem.getJoinedPaths()}; + this.addFieldError(ENTITY_TYPE_CODE_FIELD, + this.getText("error.entity.nestedBoolean.key.duplicated", args)); + } else if (NestedSearchSupport.KeyProblemType.AMBIGUOUS_SEGMENT == problem.type()) { + String[] args = {problem.getJoinedPaths(), EntitySearchKeys.KEY_SEPARATOR}; + this.addFieldError(ENTITY_TYPE_CODE_FIELD, + this.getText("error.entity.nestedBoolean.key.ambiguousSegment", args)); + } else { + String[] args = {problem.key(), String.valueOf(problem.key().length()), + String.valueOf(problem.maxKeyLength())}; + this.addFieldError(ENTITY_TYPE_CODE_FIELD, + this.getText("error.entity.nestedBoolean.key.tooLong", args)); } } } + /** + * The {@code attrname} width the keys of this type have to fit, declared by the manager the type + * belongs to. Falls back to the platform default when there is no manager to ask: {@code validate()} + * can run before one has been resolved, and a missing manager must not turn a validation pass into a + * NullPointerException. + * @return the maximum search key length, in characters. + */ + private int getMaxSearchKeyLength() { + IEntityManager entityManager = this.getEntityManager(); + return (null != entityManager) + ? entityManager.getMaxSearchKeyLength() : EntitySearchKeys.DEFAULT_MAX_KEY_LENGTH; + } + @Override public String addEntityType() { try { @@ -71,7 +118,7 @@ public String editEntityType() { IApsEntity entityType = this.getEntityPrototype(this.getEntityTypeCode()); if (null == entityType) { String[] args = {this.getEntityTypeCode()}; - this.addFieldError("entityTypeCode", this.getText("error.entity.type.null",args)); + this.addFieldError(ENTITY_TYPE_CODE_FIELD, this.getText("error.entity.type.null",args)); return INPUT; } this.initSessionParams(entityType, ApsAdminSystemConstants.EDIT); @@ -300,4 +347,4 @@ public void setAttributeName(String attributeName) { private String _attributeTypeCode; private String _attributeName; -} \ No newline at end of file +} diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_en.properties b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_en.properties index e18467ab98..125877ff35 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_en.properties +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_en.properties @@ -9,6 +9,9 @@ error.entity.type.null=An entity type with code ''{0}'' does not exist error.entity.null=An entity with code ''{0}'' does not exist error.entityManager.invalid=The component whose code is ''{0}'' is not valid error.attribute.not.exists=The attribute ''{0}'' does not exist +error.entity.nestedBoolean.key.duplicated=The search key ''{0}'' is produced by more than one attribute ({1}): rename one of them so that every searchable attribute has a unique key +error.entity.nestedBoolean.key.ambiguousSegment=The attribute path ''{0}'' cannot be encoded unambiguously: a name must not begin or end with ''{1}'', because there it cannot be told apart from the separator between two names +error.entity.nestedBoolean.key.tooLong=The search key ''{0}'' is {1} characters long, exceeding the maximum of {2}: use shorter composite/attribute names invalid.fieldvalue.minLength=The minimum length is not valid invalid.fieldvalue.maxLength=The maximum length is not valid diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_it.properties b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_it.properties index 7d3b7f8eed..2ee0d413d7 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_it.properties +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/package_it.properties @@ -9,6 +9,9 @@ error.entity.type.null=Il Tipo di Entità con codice ''{0}'' non esiste error.entity.null=Entità con codice ''{0}'' non esiste error.entityManager.invalid=Il Componente con codice ''{0}'' non è valido error.attribute.not.exists=L''Attributo con codice ''{0}'' non esiste +error.entity.nestedBoolean.key.duplicated=La chiave di ricerca ''{0}'' è prodotta da più di un attributo ({1}): rinomina uno di essi in modo che ogni attributo ricercabile abbia una chiave univoca +error.entity.nestedBoolean.key.ambiguousSegment=Il percorso di attributi ''{0}'' non può essere codificato in modo univoco: un nome non deve iniziare o terminare con ''{1}'', perché in quella posizione non è distinguibile dal separatore fra due nomi +error.entity.nestedBoolean.key.tooLong=La chiave di ricerca ''{0}'' è lunga {1} caratteri e supera il massimo di {2}: usa nomi più corti per composito/attributo invalid.fieldvalue.minLength=Il formato del campo Lunghezza minima non risulta valido invalid.fieldvalue.maxLength=Il formato del campo Lunghezza massima non risulta valido 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..63daa0053e 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,23 @@ + <%-- The filter option is offered only for boolean-like children of a Composite that is NOT + inside a List/Monolist: a boolean reached through a list is never indexed as a filter + by any search engine, so offering the flag there would let a user enable something + that can never work. --%> + +
+
+ + +
+
+ +
+
+
diff --git a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite.jsp b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite.jsp index a9e76a342b..5b3d52e5c7 100644 --- a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite.jsp +++ b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite.jsp @@ -96,6 +96,7 @@ + @@ -103,14 +104,20 @@ - - - "> - - - "> - - + + + + + + <%-- The searchable flag survives only on boolean-like Composite children + (CompositeAttribute.extractAttributeCompositeElement forces every other + type non-searchable), and a Composite reached through a list is never + indexed per attribute: both cases render as "not applicable". --%> + + + + + - - - + <%-- Complex containers (Composite, List, Monolist) are never searchable + themselves - only their children can be - so they render as "not applicable" + rather than as an unchecked box. --%> + + + + + - - - + + + + + + + + + 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..1da1041c40 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 @@ -76,7 +76,7 @@ - + @@ -88,16 +88,19 @@
+ +
@@ -114,7 +117,10 @@

-
+ <%-- Keep the panel open across the full-page reload triggered by "Set" + (changeProfileType) and by a search: the profile-type selector and its + attribute filters live inside it. --%> +
in">
@@ -129,13 +135,13 @@ for="userprofile_src_entityPrototypes"> -
+
- @@ -158,14 +164,14 @@
<%-- Date Attribute --%> - + @@ -205,7 +211,7 @@
<%-- Number Attribute --%> - + @@ -241,35 +247,75 @@
- <%-- Boolean & ThreeState --%> + <%-- ThreeState: Any, Yes, No, Not set (the unset/none state) --%> + + + +
+ + +
+ + +
+
+
+ <%-- Two-valued boolean family: Any, Yes, No --%> + test="#attribute.booleanLike && !#attribute.tristate">
+ value="#attribute.label" />
diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/EntityActionHelperNestedSearchTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/EntityActionHelperNestedSearchTest.java new file mode 100644 index 0000000000..3a11048461 --- /dev/null +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/EntityActionHelperNestedSearchTest.java @@ -0,0 +1,183 @@ +/* + * 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.apsadmin.system.entity; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.agiletec.aps.system.common.entity.model.ApsEntity; +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.ThreeStateAttribute; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the admin-console side of the nested-boolean search: {@link EntityActionHelper} + * must turn a Composite-nested boolean form field into a path-keyed {@link EntitySearchFilter}, and + * {@link EntityActionHelper#getAttributeFilterFieldName} must round-trip a nested path key back to its + * form field (the case that previously threw an NPE). + */ +class EntityActionHelperNestedSearchTest { + + private final EntityActionHelper helper = new EntityActionHelper(); + + @Test + void getAttributeFilters_shouldBuildPathKeyedFilterForCompositeNestedBoolean() { + ApsEntity prototype = entity( + booleanAttr("topFlag", true), + composite("Composite", booleanAttr("Boolean", true))); + + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + // top-level boolean submitted as "false", nested boolean submitted as "true" + lenient().when(action.getSearchFormFieldValue("topFlag_booleanFieldName")).thenReturn("false"); + lenient().when(action.getSearchFormFieldValue("Composite_Boolean_booleanFieldName")).thenReturn("true"); + + EntitySearchFilter[] filters = helper.getAttributeFilters(action, prototype); + + assertEquals(2, filters.length); + // top-level filter keyed by its own name (legacy behaviour, unchanged) + assertEquals("topFlag", filters[0].getKey()); + assertEquals("false", filters[0].getValue()); + // nested filter keyed by the path "_" - the key the DB records use + assertEquals("Composite_Boolean", filters[1].getKey()); + assertEquals("true", filters[1].getValue()); + } + + @Test + void getAttributeFilters_shouldIgnoreNestedBooleanWithNoSubmittedValue() { + ApsEntity prototype = entity(composite("Composite", booleanAttr("Boolean", true))); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + // nothing submitted -> "both" -> no filter + EntitySearchFilter[] filters = helper.getAttributeFilters(action, prototype); + assertEquals(0, filters.length); + } + + @Test + void getAttributeFilters_shouldSkipNonSearchableNestedBoolean() { + ApsEntity prototype = entity(composite("Composite", booleanAttr("Boolean", false))); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + lenient().when(action.getSearchFormFieldValue("Composite_Boolean_booleanFieldName")).thenReturn("true"); + // the nested boolean is not searchable -> not offered -> no filter even if a value is present + assertEquals(0, helper.getAttributeFilters(action, prototype).length); + } + + @Test + void getAttributeFilterFieldName_shouldResolveNestedPathKey() { + ApsEntity prototype = entity(composite("Composite", booleanAttr("Boolean", true))); + assertArrayEquals(new String[]{"Composite_Boolean_booleanFieldName"}, + helper.getAttributeFilterFieldName(prototype, "Composite_Boolean")); + } + + @Test + void getAttributeFilterFieldName_shouldReturnEmptyArrayForUnknownKey() { + // a key that is neither a top-level attribute nor a resolvable nested boolean: no NPE and, + // per Sonar S1168, an empty array (never null) - the caller treats it as "no field names" + ApsEntity prototype = entity(composite("Composite", booleanAttr("Boolean", true))); + String[] result = helper.getAttributeFilterFieldName(prototype, "Composite_Missing"); + assertNotNull(result); + assertEquals(0, result.length); + } + + // --- ThreeState (Any / Yes / No / Not set) ----------------------------- + + @Test + void getAttributeFilters_shouldBuildNullOptionFilterForThreeStateNotSet() { + ApsEntity prototype = entity(threeState("flag", true)); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + when(action.getSearchFormFieldValue("flag_booleanFieldName")).thenReturn("none"); + EntitySearchFilter[] filters = helper.getAttributeFilters(action, prototype); + assertEquals(1, filters.length); + assertEquals("flag", filters[0].getKey()); + // "Not set" is the unset state: matched by the null option, never by a value + assertTrue(filters[0].isNullOption()); + assertNull(filters[0].getValue()); + } + + @Test + void getAttributeFilters_shouldBuildValueFilterForThreeStateTrueFalse() { + ApsEntity prototype = entity(threeState("flag", true)); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + when(action.getSearchFormFieldValue("flag_booleanFieldName")).thenReturn("true"); + EntitySearchFilter[] filters = helper.getAttributeFilters(action, prototype); + assertEquals(1, filters.length); + assertEquals("true", filters[0].getValue()); + assertFalse(filters[0].isNullOption()); + } + + @Test + void getAttributeFilters_shouldSkipThreeStateWhenAny() { + ApsEntity prototype = entity(threeState("flag", true)); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + // "Any" submits a blank value -> no filter + assertEquals(0, helper.getAttributeFilters(action, prototype).length); + } + + @Test + void getAttributeFilters_plainBooleanNeverUsesNullOption() { + // "none" is only meaningful for ThreeState; a plain Boolean keeps the plain value path + ApsEntity prototype = entity(booleanAttr("flag", true)); + AbstractApsEntityFinderAction action = mock(AbstractApsEntityFinderAction.class); + when(action.getSearchFormFieldValue("flag_booleanFieldName")).thenReturn("false"); + EntitySearchFilter[] filters = helper.getAttributeFilters(action, prototype); + assertEquals(1, filters.length); + assertEquals("false", filters[0].getValue()); + assertFalse(filters[0].isNullOption()); + } + + // --- helpers ----------------------------------------------------------- + + private ThreeStateAttribute threeState(String name, boolean searchable) { + ThreeStateAttribute a = new ThreeStateAttribute(); + a.setName(name); + a.setType("ThreeState"); + a.setSearchable(searchable); + return a; + } + + private BooleanAttribute booleanAttr(String name, boolean searchable) { + BooleanAttribute a = new BooleanAttribute(); + a.setName(name); + a.setType("Boolean"); + 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 ApsEntity entity(AttributeInterface... attributes) { + ApsEntity entity = new ApsEntity(); + entity.setTypeCode("TST"); + for (AttributeInterface attribute : attributes) { + entity.addAttribute(attribute); + } + return entity; + } + +} diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java index a9f9ea4b37..4fe16aa747 100644 --- a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java @@ -2,30 +2,37 @@ import static com.agiletec.apsadmin.system.entity.type.ICompositeAttributeConfigAction.COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM; import static com.agiletec.apsadmin.system.entity.type.IEntityTypeConfigAction.ENTITY_TYPE_ON_EDIT_SESSION_PARAM; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.agiletec.aps.system.common.entity.IEntityManager; 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.TextAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import com.agiletec.apsadmin.system.BaseAction; -import org.apache.struts2.action.Action; -import org.apache.struts2.text.TextProvider; -import java.util.HashMap; -import java.util.Map; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.Map; +import org.apache.struts2.action.Action; +import org.apache.struts2.text.TextProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.BeanFactory; @@ -45,15 +52,17 @@ class CompositeAttributeConfigActionTest { @InjectMocks private CompositeAttributeConfigAction action; + private CompositeAttribute compositeAttribute; + @BeforeEach void setUp() { - Mockito.when(request.getSession()).thenReturn(session); + when(request.getSession()).thenReturn(session); - CompositeAttribute compositeAttribute = new CompositeAttribute(); + this.compositeAttribute = new CompositeAttribute(); compositeAttribute.setName(COMPOSITE_ATTRIBUTE_NAME); addTextAttribute(compositeAttribute, "attribute1"); addTextAttribute(compositeAttribute, "attribute2"); - Mockito.lenient().when(session.getAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM)).thenReturn(compositeAttribute); + lenient().when(session.getAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM)).thenReturn(compositeAttribute); } private void addTextAttribute(CompositeAttribute compositeAttribute, String attributeName) { @@ -68,16 +77,16 @@ void testMoveAttribute() { action.setMovement(ApsAdminSystemConstants.MOVEMENT_UP_CODE); action.setAttributeIndex(1); action.moveAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } @Test void testRemoveAttributeElement() { action.setAttributeIndex(0); action.removeAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } @@ -85,35 +94,111 @@ void testRemoveAttributeElement() { void testSaveAttributeElement() { String entityManagerName = "EntityManagerName"; String attributeTypeCode = "typeCode"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + IEntityManager entityManager = mock(IEntityManager.class); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + action.setAttributeTypeCode(attributeTypeCode); + action.saveAttributeElement(); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + } + + @Test + void searchableFlagIsClearedWhenTheCompositeIsNestedInAList() { + MonoListAttribute list = new MonoListAttribute(); + list.setName("rows"); + when(session.getAttribute(IListElementAttributeConfigAction.LIST_ATTRIBUTE_ON_EDIT_SESSION_PARAM)) + .thenReturn(list); + + AttributeInterface saved = saveBooleanAttributeElement("featured", true); + + // a boolean reached through a list is indexed by no engine, so the flag must not survive + Assertions.assertFalse(saved.isSearchable()); + } + + @Test + void searchableFlagIsKeptWhenTheCompositeIsNotNestedInAList() { + AttributeInterface saved = saveBooleanAttributeElement("featured", true); + + Assertions.assertTrue(saved.isSearchable()); + } + + /** + * Drive {@code saveAttributeElement} for a Boolean child and return the attribute it added to the + * Composite being edited. + */ + private AttributeInterface saveBooleanAttributeElement(String attributeName, boolean searchable) { + String entityManagerName = "EntityManagerName"; + String attributeTypeCode = "Boolean"; + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + .thenReturn(entityManagerName); + Map attributeTypes = new HashMap<>(); + attributeTypes.put(attributeTypeCode, new BooleanAttribute()); + IEntityManager entityManager = mock(IEntityManager.class); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); action.setAttributeTypeCode(attributeTypeCode); + action.setAttributeName(attributeName); + action.setSearchable(searchable); + action.saveAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + + return compositeAttribute.getAttribute(attributeName); + } + + @Test + void testNestedSearchableOptionSupportedForBooleanLikes() { + String entityManagerName = "EntityManagerName"; + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + .thenReturn(entityManagerName); + Map attributeTypes = new HashMap<>(); + attributeTypes.put("Boolean", new BooleanAttribute()); + attributeTypes.put("CheckBox", new CheckBoxAttribute()); + attributeTypes.put("ThreeState", new ThreeStateAttribute()); + attributeTypes.put("Text", new TextAttribute()); + IEntityManager entityManager = mock(IEntityManager.class); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + + // every boolean-like composite child may be flagged searchable; other types may not + Assertions.assertTrue(action.isNestedSearchableOptionSupported("Boolean")); + Assertions.assertTrue(action.isNestedSearchableOptionSupported("CheckBox")); + Assertions.assertTrue(action.isNestedSearchableOptionSupported("ThreeState")); + Assertions.assertFalse(action.isNestedSearchableOptionSupported("Text")); + } + + @Test + void testNestedSearchableOptionSupportedHandlesException() { + String entityManagerName = "EntityManagerName"; + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + .thenReturn(entityManagerName); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + + Assertions.assertFalse(action.isNestedSearchableOptionSupported("Boolean")); } @Test void shouldMethodNotAddAttributeElement() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); String attributeTypeCode = "typeCode"; Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode("attributeTypeCodeNotExistent"); - Mockito.when(textProvider.getText(any(), (String[]) any())).thenReturn("label"); + when(textProvider.getText(any(), (String[]) any())).thenReturn("label"); String result = action.addAttributeElement(); @@ -125,12 +210,12 @@ void shouldMethodNotAddAttributeElement() { void shouldMethodAddAttributeElementRaiseFailure() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode(""); String result = action.addAttributeElement(); @@ -142,15 +227,15 @@ void shouldMethodAddAttributeElementRaiseFailure() { void shouldMethodAddAttributeElement() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); String attributeTypeCode = "typeCode"; Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode(attributeTypeCode); String result = action.addAttributeElement(); @@ -163,31 +248,31 @@ void shouldMethodAddAttributeElement() { @Test void shouldMethodSaveCompositeAttributeSaveComposite() { - IApsEntity entity = Mockito.mock(IApsEntity.class); - Mockito.when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); + IApsEntity entity = mock(IApsEntity.class); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); - CompositeAttribute compositeAttribute = new CompositeAttribute(); - compositeAttribute.setName(COMPOSITE_ATTRIBUTE_NAME); - Mockito.when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(compositeAttribute); + CompositeAttribute savedComposite = new CompositeAttribute(); + savedComposite.setName(COMPOSITE_ATTRIBUTE_NAME); + when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(savedComposite); action.saveCompositeAttribute(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); - Mockito.verify(session, Mockito.times(1)) + verify(session, times(1)) .removeAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM); } @Test void shouldMethodSaveCompositeAttributeSaveMonolist() { MonoListAttribute attribute = new MonoListAttribute(); - IApsEntity entity = Mockito.mock(IApsEntity.class); - Mockito.when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(attribute); - Mockito.when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); + IApsEntity entity = mock(IApsEntity.class); + when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(attribute); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); action.saveCompositeAttribute(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); } } diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigActionTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigActionTest.java index 84185f2228..e698a2a20d 100644 --- a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigActionTest.java +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/EntityTypeConfigActionTest.java @@ -2,12 +2,21 @@ import static com.agiletec.apsadmin.system.entity.type.IEntityTypeConfigAction.ENTITY_TYPE_ON_EDIT_SESSION_PARAM; import static com.agiletec.apsadmin.system.entity.type.IEntityTypeConfigAction.ENTITY_TYPE_OPERATION_ID_SESSION_PARAM; +import static org.mockito.ArgumentMatchers.eq; +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.IEntityManager; +import com.agiletec.aps.system.common.entity.model.ApsEntity; 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.CompositeAttribute; import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; +import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import org.apache.struts2.action.Action; +import org.apache.struts2.text.TextProvider; import java.util.HashMap; import java.util.Map; import jakarta.servlet.http.HttpServletRequest; @@ -16,9 +25,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.BeanFactory; @@ -34,6 +43,8 @@ class EntityTypeConfigActionTest { private IApsEntity entityType; @Mock private BeanFactory beanFactory; + @Mock + private TextProvider textProvider; @InjectMocks @Spy @@ -41,24 +52,92 @@ class EntityTypeConfigActionTest { @BeforeEach void setUp() { - Mockito.when(request.getSession()).thenReturn(session); + when(request.getSession()).thenReturn(session); } @Test void testAddAttribute() { String entityManagerName = "EntityManagerName"; String attributeTypeCode = "typeCode"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entityType); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); - Mockito.when(session.getAttribute(ENTITY_TYPE_OPERATION_ID_SESSION_PARAM)).thenReturn(1); + IEntityManager entityManager = mock(IEntityManager.class); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entityType); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + when(session.getAttribute(ENTITY_TYPE_OPERATION_ID_SESSION_PARAM)).thenReturn(1); action.setAttributeTypeCode(attributeTypeCode); String result = action.addAttribute(); Assertions.assertEquals(Action.SUCCESS, result); } + + @Test + void validateShouldRejectDuplicatedNestedBooleanSearchKey() { + // top-level 'compo_flag' and composite 'compo' child 'flag' write the same DB attrname + ApsEntity type = entityTypeOnEdit(); + type.addAttribute(booleanAttribute("compo_flag")); + type.addAttribute(compositeWith("compo", booleanAttribute("flag"))); + + action.validate(); + + Assertions.assertTrue(action.hasFieldErrors()); + Assertions.assertEquals(1, action.getFieldErrors().get("entityTypeCode").size()); + ArgumentCaptor args = ArgumentCaptor.forClass(String[].class); + verify(textProvider) + .getText(eq("error.entity.nestedBoolean.key.duplicated"), args.capture()); + Assertions.assertEquals("compo_flag", args.getValue()[0]); + Assertions.assertEquals("compo_flag, compo > flag", args.getValue()[1]); + } + + @Test + void validateShouldRejectNestedBooleanSearchKeyLongerThanTheColumn() { + ApsEntity type = entityTypeOnEdit(); + type.addAttribute(compositeWith("c".repeat(260), booleanAttribute("flag"))); + + action.validate(); + + Assertions.assertTrue(action.hasFieldErrors()); + ArgumentCaptor args = ArgumentCaptor.forClass(String[].class); + verify(textProvider) + .getText(eq("error.entity.nestedBoolean.key.tooLong"), args.capture()); + Assertions.assertEquals("265", args.getValue()[1]); + Assertions.assertEquals("255", args.getValue()[2]); + } + + @Test + void validateShouldAcceptSoundNestedBooleanSearchKeys() { + ApsEntity type = entityTypeOnEdit(); + type.addAttribute(booleanAttribute("flag")); + type.addAttribute(compositeWith("compo", booleanAttribute("certified"))); + + action.validate(); + + Assertions.assertFalse(action.hasFieldErrors()); + } + + private ApsEntity entityTypeOnEdit() { + ApsEntity type = new ApsEntity(); + type.setTypeCode("TST"); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(type); + when(session.getAttribute(ENTITY_TYPE_OPERATION_ID_SESSION_PARAM)) + .thenReturn(ApsAdminSystemConstants.EDIT); + return type; + } + + private BooleanAttribute booleanAttribute(String name) { + BooleanAttribute attribute = new BooleanAttribute(); + attribute.setName(name); + attribute.setSearchable(true); + return attribute; + } + + private CompositeAttribute compositeWith(String name, AttributeInterface child) { + CompositeAttribute composite = new CompositeAttribute(); + composite.setName(name); + composite.getAttributes().add(child); + composite.getAttributeMap().put(child.getName(), child); + return composite; + } } diff --git a/advcontentsearch.postman_collection.json b/advcontentsearch.postman_collection.json new file mode 100644 index 0000000000..8c36720ece --- /dev/null +++ b/advcontentsearch.postman_collection.json @@ -0,0 +1,1365 @@ +{ + "info": { + "_postman_id": "esb1154-advcontentsearch-0000-0000-000000000001", + "name": "Entando - AdvContentSearch (legit tests)", + "description": "Legit, real-world requests for the AdvContentSearchController endpoints:\n\n- GET {{baseUrl}}/contents\n- GET {{baseUrl}}/facetedcontents\n\nAll requests run as **guest** (the endpoint has no @RestAccessControl). Supplying a Bearer token in the `token` collection variable would switch results to that user's visibility.\n\nExpectations are based on the actual published content of the demo app (all `free`-group, English):\n\n| id | type | title |\n|-------|------|-------|\n| BNR2 | BNR (Banner) | Hello, world! |\n| BNR3 | BNR (Banner) | A Modern Platform for Modern UX |\n| NWS4 | NWS (News) | Why You Need a Micro Frontend Platform for Kubernetes |\n| NWS5 | NWS (News) | Entando and JHipster: How It Works |\n| TCL6 | TCL (2 columns) | Sample - About Us |\n\nEnglish content, so full-text searches use `lang=en`. Every request asserts HTTP 200, an empty `errors` array, `metaData`, and a **non-empty** payload; content assertions use the type codes and the known ids above.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "const contentType = pm.response.headers.get('Content-Type') || '';", + "if (contentType.indexOf('json') !== -1) {", + " const body = pm.response.json();", + " pm.test('No errors in the response envelope', function () {", + " pm.expect(body.errors, JSON.stringify(body.errors)).to.be.an('array').that.is.empty;", + " });", + " pm.test('Response has metaData', function () {", + " pm.expect(body).to.have.property('metaData');", + " });", + "}" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080/entando-de-app/api/plugins/advcontentsearch", + "type": "string" + }, + { + "key": "token", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "contents", + "item": [ + { + "name": "C0 - All published contents", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('includes the known free-group contents', function () {", + " ['BNR2', 'BNR3', 'NWS4', 'NWS5', 'TCL6'].forEach(id => pm.expect(body.payload).to.include(id));", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?sort=created&direction=DESC&pageSize=25", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "sort", + "value": "created" + }, + { + "key": "direction", + "value": "DESC" + }, + { + "key": "pageSize", + "value": "25" + } + ] + }, + "description": "All published contents visible to a guest, newest first." + } + }, + { + "name": "C1 - News (NWS)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('all returned contents are News (NWS*)', function () {", + " body.payload.forEach(id => pm.expect(id).to.match(/^NWS/));", + "});", + "pm.test('includes NWS4 and NWS5', function () {", + " pm.expect(body.payload).to.include('NWS4');", + " pm.expect(body.payload).to.include('NWS5');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].attribute=typeCode&filters[0].operator=eq&filters[0].value=NWS&sort=created&direction=DESC", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "typeCode" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "NWS" + }, + { + "key": "sort", + "value": "created" + }, + { + "key": "direction", + "value": "DESC" + } + ] + }, + "description": "News contents (NWS), newest first." + } + }, + { + "name": "C2 - Banners (BNR)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('all returned contents are Banners (BNR*)', function () {", + " body.payload.forEach(id => pm.expect(id).to.match(/^BNR/));", + "});", + "pm.test('includes BNR2 and BNR3', function () {", + " pm.expect(body.payload).to.include('BNR2');", + " pm.expect(body.payload).to.include('BNR3');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].attribute=typeCode&filters[0].operator=eq&filters[0].value=BNR", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "typeCode" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "BNR" + } + ] + }, + "description": "Banner contents (BNR)." + } + }, + { + "name": "C3 - Everything except News (not NWS)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('no News ids are returned', function () {", + " body.payload.forEach(id => pm.expect(id).to.not.match(/^NWS/));", + "});", + "pm.test('includes non-News contents (BNR2, TCL6)', function () {", + " pm.expect(body.payload).to.include('BNR2');", + " pm.expect(body.payload).to.include('TCL6');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].attribute=typeCode&filters[0].operator=not&filters[0].value=NWS", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "typeCode" + }, + { + "key": "filters[0].operator", + "value": "not" + }, + { + "key": "filters[0].value", + "value": "NWS" + } + ] + }, + "description": "Negated type filter: all published contents that are not News." + } + }, + { + "name": "C4 - Banners OR News (doubleFilters OR-group)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('only Banners or News are returned', function () {", + " body.payload.forEach(id => pm.expect(id).to.match(/^(BNR|NWS)/));", + "});", + "pm.test('includes BNR2 and NWS4', function () {", + " pm.expect(body.payload).to.include('BNR2');", + " pm.expect(body.payload).to.include('NWS4');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?doubleFilters[0][0].attribute=typeCode&doubleFilters[0][0].operator=eq&doubleFilters[0][0].value=BNR&doubleFilters[0][1].attribute=typeCode&doubleFilters[0][1].operator=eq&doubleFilters[0][1].value=NWS&sort=modified&direction=DESC", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "doubleFilters[0][0].attribute", + "value": "typeCode" + }, + { + "key": "doubleFilters[0][0].operator", + "value": "eq" + }, + { + "key": "doubleFilters[0][0].value", + "value": "BNR" + }, + { + "key": "doubleFilters[0][1].attribute", + "value": "typeCode" + }, + { + "key": "doubleFilters[0][1].operator", + "value": "eq" + }, + { + "key": "doubleFilters[0][1].value", + "value": "NWS" + }, + { + "key": "sort", + "value": "modified" + }, + { + "key": "direction", + "value": "DESC" + } + ] + }, + "description": "OR-group via doubleFilters: Banners OR News in a single result set." + } + }, + { + "name": "C5 - Full-text 'Entando' (en)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('matches the Entando/JHipster news (NWS5)', function () {", + " pm.expect(body.payload).to.include('NWS5');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?text=Entando&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "text", + "value": "Entando" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Full-text search over the English index for 'Entando'. Matches e.g. NWS5 ('Entando and JHipster')." + } + }, + { + "name": "C6 - Full-text 'Kubernetes' (en)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('matches the Kubernetes micro-frontend news (NWS4)', function () {", + " pm.expect(body.payload).to.include('NWS4');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?text=Kubernetes&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "text", + "value": "Kubernetes" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Full-text search over the English index for 'Kubernetes'. Matches e.g. NWS4." + } + }, + { + "name": "C7 - Created-after date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].attribute=created&filters[0].operator=gt&filters[0].type=date&filters[0].value=2000-01-01 00:00:00&sort=created&direction=ASC", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "created" + }, + { + "key": "filters[0].operator", + "value": "gt" + }, + { + "key": "filters[0].type", + "value": "date" + }, + { + "key": "filters[0].value", + "value": "2000-01-01 00:00:00" + }, + { + "key": "sort", + "value": "created" + }, + { + "key": "direction", + "value": "ASC" + } + ] + }, + "description": "Contents created after 2000-01-01 (all demo contents were created in 2026), oldest first." + } + }, + { + "name": "C8 - Pagination (page 1, size 2)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('at most pageSize (2) items', function () {", + " pm.expect(body.payload.length).to.be.at.most(2);", + "});", + "pm.test('metaData reflects the request', function () {", + " pm.expect(body.metaData.page).to.eql(1);", + " pm.expect(body.metaData.pageSize).to.eql(2);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?page=1&pageSize=2&sort=created&direction=DESC", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "2" + }, + { + "key": "sort", + "value": "created" + }, + { + "key": "direction", + "value": "DESC" + } + ] + }, + "description": "Default search, paginated. Verifies pagination metadata." + } + } + ] + }, + { + "name": "facetedcontents", + "item": [ + { + "name": "F1 - Faceted defaults (all visible contents)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload.contentsId is a non-empty array', function () {", + " pm.expect(body.payload.contentsId).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('includes the known free-group contents', function () {", + " ['BNR2', 'NWS4', 'TCL6'].forEach(id => pm.expect(body.payload.contentsId).to.include(id));", + "});", + "pm.test('payload.occurrences (category facet counts) is present', function () {", + " pm.expect(body.payload).to.have.property('occurrences');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/facetedcontents?pageSize=25", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "facetedcontents" + ], + "query": [ + { + "key": "pageSize", + "value": "25" + } + ] + }, + "description": "Faceted search with defaults. Returns content ids plus category facet occurrences (occurrences may be empty when contents carry no tree categories)." + } + }, + { + "name": "F2 - Faceted News (NWS)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload.contentsId is a non-empty array', function () {", + " pm.expect(body.payload.contentsId).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('all faceted contents are News (NWS*)', function () {", + " body.payload.contentsId.forEach(id => pm.expect(id).to.match(/^NWS/));", + "});", + "pm.test('includes NWS4 and NWS5', function () {", + " pm.expect(body.payload.contentsId).to.include('NWS4');", + " pm.expect(body.payload.contentsId).to.include('NWS5');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/facetedcontents?filters[0].attribute=typeCode&filters[0].operator=eq&filters[0].value=NWS", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "facetedcontents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "typeCode" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "NWS" + } + ] + }, + "description": "Faceted search restricted to News." + } + }, + { + "name": "F3 - Faceted full-text 'Entando' (en)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload.contentsId is a non-empty array', function () {", + " pm.expect(body.payload.contentsId).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('matches NWS5', function () {", + " pm.expect(body.payload.contentsId).to.include('NWS5');", + "});", + "pm.test('occurrences present', function () {", + " pm.expect(body.payload).to.have.property('occurrences');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/facetedcontents?text=Entando&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "facetedcontents" + ], + "query": [ + { + "key": "text", + "value": "Entando" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Faceted full-text search over the English index for 'Entando'." + } + } + ] + }, + { + "name": "boolean attributes", + "description": "Boolean-like attribute search (Boolean, CheckBox, ThreeState) - top-level and nested inside Composite attributes.\n\n**Two kinds of request in this folder.**\n- **Data-backed (B1, B2, B3, B10)** - the test dataset defines content type `BLT` with top-level `Flag` (Boolean) and `Check` (CheckBox) flagged searchable=true, over four PUBLIC free-group contents: BLT1 (Flag=true, Check=true), BLT2 (false, false), BLT3 (true, false), BLT4 (false, Check never set). These assert real matches, on an instance seeded with the test dataset and reindexed - the same prerequisite as the `contents` folder.\n- **Request-shape only (B4-B9, B11)** - no seeded type flags a *Composite child* searchable, and no seeded ThreeState is searchable, so these document the correct URL form and assert HTTP 200 with a possibly empty payload. To make them return data: flag the composite child searchable=true in the content type (a Composite child is indexed iff it is flagged searchable - the same gate as a top-level boolean-like; there is no environment flag) and reindex.\n\n**Addressing.** A top-level attribute uses its own name, raw (entityAttr=Flag). One nested in a Composite uses its full path, the segments joined with '_' (entityAttr=promo_active, or entityAttr=layout_flags_sticky when doubly nested), and is single-valued (a Composite occurs at most once per document per lang). Because '_' is also legal inside a name, it is **escaped by doubling within each segment**: Composite `press_kit` + child `published` is `press__kit_published`, not `press_kit_published` - see B11. List/Monolist booleans are not searchable at all.\n\n**Boolean/CheckBox:** type=boolean is required and value must be true/false; gt/lt or a non-true/false value is rejected with HTTP 400 (not shown, as the collection asserts 200 for every request). An unset Boolean/CheckBox is indexed as false, so value=false also matches contents that never touched the field - see B10.\n\n**ThreeState:** indexed as Solr string (not boolean) because its third, uninitialized state can't fit a two-valued BoolField - use type=string and value=true/false/none. An unset ThreeState is indexed as the literal \"none\", not dropped.", + "item": [ + { + "name": "B1 - Top-level boolean = true (BLT fixture)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('returns exactly the BLT contents whose Flag is true', function () {", + " pm.expect(body.payload).to.include('BLT1');", + " pm.expect(body.payload).to.include('BLT3');", + " pm.expect(body.payload).to.not.include('BLT2');", + " pm.expect(body.payload).to.not.include('BLT4');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=Flag&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=true&filters[1].attribute=typeCode&filters[1].operator=eq&filters[1].value=BLT&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "Flag" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "filters[1].attribute", + "value": "typeCode" + }, + { + "key": "filters[1].operator", + "value": "eq" + }, + { + "key": "filters[1].value", + "value": "BLT" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Top-level Boolean `Flag` = true -> Solr field `en_Flag:true`, scoped to the BLT type.\n\nReal data: the test dataset defines content type `BLT` with `Flag` (Boolean) and `Check` (CheckBox) both flagged searchable=true, over four PUBLIC free-group contents: BLT1 (Flag=true), BLT2 (false), BLT3 (true), BLT4 (false). Expected: BLT1 and BLT3.\n\nRequires an instance seeded with the test dataset and reindexed - the same prerequisite the `contents` folder already has." + } + }, + { + "name": "B2 - Top-level boolean = false (BLT fixture)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('returns exactly the BLT contents whose Flag is false', function () {", + " pm.expect(body.payload).to.include('BLT2');", + " pm.expect(body.payload).to.include('BLT4');", + " pm.expect(body.payload).to.not.include('BLT1');", + " pm.expect(body.payload).to.not.include('BLT3');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=Flag&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=false&filters[1].attribute=typeCode&filters[1].operator=eq&filters[1].value=BLT&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "Flag" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "false" + }, + { + "key": "filters[1].attribute", + "value": "typeCode" + }, + { + "key": "filters[1].operator", + "value": "eq" + }, + { + "key": "filters[1].value", + "value": "BLT" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Top-level Boolean `Flag` = false -> `en_Flag:false`. Expected: BLT2 and BLT4.\n\nNote that `false` is a stored value, not an absence: a Boolean/CheckBox that was never touched is indexed as false and matches here. Use ThreeState when 'not answered' must differ from 'answered no'." + } + }, + { + "name": "B3 - Top-level boolean negation (not true)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('excludes the Flag=true contents', function () {", + " pm.expect(body.payload).to.not.include('BLT1');", + " pm.expect(body.payload).to.not.include('BLT3');", + "});", + "pm.test('returns the remaining BLT contents', function () {", + " pm.expect(body.payload).to.include('BLT2');", + " pm.expect(body.payload).to.include('BLT4');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=Flag&filters[0].type=boolean&filters[0].operator=not&filters[0].value=true&filters[1].attribute=typeCode&filters[1].operator=eq&filters[1].value=BLT&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "Flag" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "not" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "filters[1].attribute", + "value": "typeCode" + }, + { + "key": "filters[1].operator", + "value": "eq" + }, + { + "key": "filters[1].value", + "value": "BLT" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Negation: NOT (`Flag` = true) -> Solr `-en_Flag:true`, scoped to BLT so the negation does not match every other content type (an unscoped negation matches everything that lacks the field).\n\nExpected: BLT2 and BLT4 - the same result as B2 here, because every BLT content has the field set. The two would differ if some content lacked `en_Flag` altogether: `not true` matches missing fields, `eq false` does not." + } + }, + { + "name": "B4 - Boolean inside a Composite (full path)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array (empty is OK: demo app has no such composite)', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=promo_active&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "promo_active" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Boolean `active` nested in Composite `promo`. `entityAttr` is the full composite path `promo_active` -> single-valued Solr field `en_promo_active:true`. A Composite boolean-like child is indexed iff it is flagged searchable=true (inherited from the content-type definition - the same gate as a top-level boolean-like; no environment flag)." + } + }, + { + "name": "B5 - Boolean in a nested Composite (double path)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array (empty is OK: demo app has no such composite)', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=layout_flags_sticky&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "layout_flags_sticky" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Boolean `sticky` nested two Composites deep (`layout` -> `flags` -> `sticky`). `entityAttr` is the full path `layout_flags_sticky` -> Solr field `en_layout_flags_sticky:true`." + } + }, + { + "name": "B6 - Composite boolean AND type filter", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].attribute=typeCode&filters[0].operator=eq&filters[0].value=NWS&filters[1].entityAttr=promo_active&filters[1].type=boolean&filters[1].operator=eq&filters[1].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].attribute", + "value": "typeCode" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "NWS" + }, + { + "key": "filters[1].entityAttr", + "value": "promo_active" + }, + { + "key": "filters[1].type", + "value": "boolean" + }, + { + "key": "filters[1].operator", + "value": "eq" + }, + { + "key": "filters[1].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "AND of two filters: News (typeCode=NWS) whose Composite boolean `promo.active` is true. Multiple filters[i] entries are always AND-combined." + } + }, + { + "name": "B7 - Faceted search on a Composite boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload.contentsId is an array (empty is OK)', function () {", + " pm.expect(body.payload.contentsId).to.be.an('array');", + "});", + "pm.test('occurrences present', function () {", + " pm.expect(body.payload).to.have.property('occurrences');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/facetedcontents?filters[0].entityAttr=promo_active&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "facetedcontents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "promo_active" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Same Composite boolean filter against the faceted endpoint: returns matching content ids plus category facet occurrences." + } + }, + { + "name": "B8 - Top-level ThreeState = none (unset)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array (empty is OK: demo app has no ThreeState attribute)', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=confirmed&filters[0].type=string&filters[0].operator=eq&filters[0].value=none&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "confirmed" + }, + { + "key": "filters[0].type", + "value": "string" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "none" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Top-level ThreeState attribute `confirmed`, targeting its uninitialized state -> Solr field `en_confirmed:none`. Unlike Boolean/CheckBox (unset coerces to false), an unset ThreeState is indexed as the literal string `none`, so it is directly term-queryable. Note type=string, not boolean. The top-level attribute must be flagged searchable in the content type." + } + }, + { + "name": "B9 - ThreeState inside a Composite = true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array (empty is OK: demo app has no such composite)', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=promo_confirmed&filters[0].type=string&filters[0].operator=eq&filters[0].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "promo_confirmed" + }, + { + "key": "filters[0].type", + "value": "string" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "ThreeState `confirmed` nested in Composite `promo`. `entityAttr` is the full composite path `promo_confirmed` -> single-valued Solr field `en_promo_confirmed:true` (string type, literal true/false/none). Same eligibility as a nested Boolean/CheckBox: indexed iff the composite child is flagged searchable=true (inherited from the content type; no environment flag)." + } + }, + { + "name": "B10 - CheckBox = false also matches a never-touched field", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is a non-empty array', function () {", + " pm.expect(body.payload).to.be.an('array').that.is.not.empty;", + "});", + "pm.test('matches the explicit false values', function () {", + " pm.expect(body.payload).to.include('BLT2');", + " pm.expect(body.payload).to.include('BLT3');", + "});", + "pm.test('and also the content that never set the CheckBox (unset coerces to false)', function () {", + " pm.expect(body.payload).to.include('BLT4');", + "});", + "pm.test('excludes the only Check=true content', function () {", + " pm.expect(body.payload).to.not.include('BLT1');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=Check&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=false&filters[1].attribute=typeCode&filters[1].operator=eq&filters[1].value=BLT&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "Check" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "false" + }, + { + "key": "filters[1].attribute", + "value": "typeCode" + }, + { + "key": "filters[1].operator", + "value": "eq" + }, + { + "key": "filters[1].value", + "value": "BLT" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "CheckBox `Check` = false -> `en_Check:false`. A CheckBox is filtered exactly like a Boolean (type=boolean); only the editor widget differs.\n\nThe point of this request: BLT4 defines **no** `Check` value at all, yet it matches. An unset Boolean/CheckBox coerces to `false` at index time, so `value=false` returns both the contents whose author answered 'no' (BLT2, BLT3) and those who never touched the field (BLT4). This is the single most common surprise with boolean filters - if you need to tell the two apart, model the attribute as a ThreeState." + } + }, + { + "name": "B11 - Composite/attribute name containing '_' (escaped key)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const body = pm.response.json();", + "pm.test('payload is an array (empty is OK: no seeded type uses an underscored composite name)', function () {", + " pm.expect(body.payload).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/contents?filters[0].entityAttr=press__kit_published&filters[0].type=boolean&filters[0].operator=eq&filters[0].value=true&lang=en", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contents" + ], + "query": [ + { + "key": "filters[0].entityAttr", + "value": "press__kit_published" + }, + { + "key": "filters[0].type", + "value": "boolean" + }, + { + "key": "filters[0].operator", + "value": "eq" + }, + { + "key": "filters[0].value", + "value": "true" + }, + { + "key": "lang", + "value": "en" + } + ] + }, + "description": "Boolean `published` nested in a Composite named `press_kit`.\n\nThe path separator is `_`, and `_` is also legal **inside** a name, so it is escaped by doubling within each segment: the key is `press__kit_published`, NOT `press_kit_published` - the latter is the key of a Composite `press` with a child `kit_published`. Getting this wrong returns an empty result set with no error, so build the key by escaping each segment (or read it from `GET /plugins/cms/contentTypes/{code}`) rather than by concatenating names.\n\nA **top-level** attribute keeps its raw name, unescaped - only nested paths are escaped.\n\nRequest-shape example: no seeded type has an underscore in a composite name, so this returns an empty payload." + } + } + ] + } + ] +} diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java index b8454945aa..7b8578bdb2 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java @@ -145,7 +145,7 @@ public Map getSmallContentTypesMap() { @Override public String getViewPage(String contentId) { Content type = this.getTypeById(contentId); - return type.getViewPage(); + return (null != type) ? type.getViewPage() : null; } /** @@ -157,7 +157,7 @@ public String getViewPage(String contentId) { @Override public String getDefaultModel(String contentId) { Content type = this.getTypeById(contentId); - return type.getDefaultModel(); + return (null != type) ? type.getDefaultModel() : null; } /** @@ -170,7 +170,7 @@ public String getDefaultModel(String contentId) { @Override public String getListModel(String contentId) { Content type = this.getTypeById(contentId); - return type.getListModel(); + return (null != type) ? type.getListModel() : null; } /** diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/widget/UserFilterOptionBean.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/widget/UserFilterOptionBean.java index e4907ec85f..58227f1c64 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/widget/UserFilterOptionBean.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/widget/UserFilterOptionBean.java @@ -256,15 +256,17 @@ public EntitySearchFilter getEntityFilter() throws EntException { } else if (attribute instanceof BooleanAttribute) { String value = this.getFormFieldValues().get(this.getFormFieldNames()[0]); String ignore = this.getFormFieldValues().get(this.getFormFieldNames()[1]); - if (null != ignore) { - return null; - } else if (null == value + // "Ignore" ticked, nothing selected, or the three-state "both": the user is not + // filtering on this attribute, and null is the caller's "no filter" signal. + // This used to build a nullOption filter, i.e. "contents with NO indexed value for + // this attribute", which for a boolean is always the empty set: BooleanAttribute + // always writes a 'true'/'false' search row. The widget therefore returned nothing + // as soon as a boolean user filter was rendered and left untouched. + if (null != ignore || null == value || value.equals("both")) {//special option for three state Attribute - filter = new EntitySearchFilter(attribute.getName(), true); - filter.setNullOption(true); - } else { - filter = new EntitySearchFilter(attribute.getName(), true, value, false); + return null; } + filter = new EntitySearchFilter(attribute.getName(), true, value, false); } else if (attribute instanceof NumberAttribute) { String start = this.getFormFieldValues().get(this.getFormFieldNames()[0]); String end = this.getFormFieldValues().get(this.getFormFieldNames()[1]); diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java index 7184c476a7..6926e19b41 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java @@ -268,6 +268,9 @@ protected void generateAndSetResourceId(ResourceInterface resource, String id) t @Override public void updateResource(ResourceDataBean bean) throws EntException { ResourceInterface oldResource = this.loadResource(bean.getResourceId()); + if (null == oldResource) { + throw new EntException("Error updating resource: no resource found with id " + bean.getResourceId()); + } try { if (null == bean.getInputStream()) { oldResource.setDescription(LabelSanitizer.stripMarkup(bean.getDescr())); @@ -563,6 +566,10 @@ protected void startResourceReloaderThread(String resourceTypeCode, int operatio protected void refreshMasterFileNames(String resourceId) { try { ResourceInterface resource = this.loadResource(resourceId); + if (null == resource) { + logger.warn("Resource '{}' not found, skipping master file name refresh", resourceId); + return; + } if (resource.isMultiInstance()) { ResourceInstance instance = ((AbstractMultiInstanceResource) resource).getInstance(0, null); @@ -584,6 +591,10 @@ protected void refreshMasterFileNames(String resourceId) { protected void refreshResourceInstances(String resourceId) { try { ResourceInterface resource = this.loadResource(resourceId); + if (null == resource) { + logger.warn("Resource '{}' not found, skipping instance refresh", resourceId); + return; + } resource.reloadResourceInstances(); this.updateResource(resource); } catch (Throwable t) { diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/searchengine/IndexerDAO.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/searchengine/IndexerDAO.java index 088ee41119..164fbb9209 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/searchengine/IndexerDAO.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/searchengine/IndexerDAO.java @@ -19,6 +19,7 @@ import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute; import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface; +import org.entando.entando.aps.system.common.entity.search.SearchFieldType; import com.agiletec.aps.system.common.tree.ITreeNode; import com.agiletec.aps.system.common.tree.ITreeNodeManager; import org.entando.entando.ent.exception.EntException; @@ -150,8 +151,17 @@ protected Document createDocument(IApsEntity entity) throws EntException { protected void indexAttribute(Document document, AttributeInterface attribute, Lang lang) { attribute.setRenderingLang(lang.getCode()); - if (attribute instanceof IndexableAttributeInterface - || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute) && attribute.isSearchable())) { + // Only attributes that carry indexable content get a Lucene field. The condition used to + // also admit a searchable Date or Number attribute, which could never widen it: both types + // implement IndexableAttributeInterface themselves, so that clause was dropped as dead. + // + // DELIBERATE DIVERGENCE from the Solr engine: boolean-like attributes (Boolean, CheckBox, + // ThreeState) are NOT indexed here, and must not be. On this engine attribute filtering is + // served by the DB search tables (contentsearch/workcontentsearch), which is where a boolean's + // searchable flag writes its row; Lucene only provides full text. Switching this test to the + // engine-wide attribute.hasSearchField() would therefore start writing boolean fields into + // every Lucene document - a change of index contents, not a refactor. + if (attribute instanceof IndexableAttributeInterface) { Object[] values = this.extractValuesToIndex(attribute); if (null == values[0]) { return; @@ -170,7 +180,7 @@ protected void indexAttribute(Document document, AttributeInterface attribute, L document.add(new TextField(lang.getCode(), valueToIndex, Field.Store.YES)); } } - boolean isDate = (attribute instanceof DateAttribute); + boolean isDate = (SearchFieldType.DATE == attribute.getSearchFieldType()); String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName(); this.indexValue(document, fieldName, valueToIndex, number, isDate); if (null == attribute.getRoles()) { @@ -207,16 +217,34 @@ protected void scanComplexAttribute(Document document, AttributeInterface attrib } } + /** + * The pair this engine needs for one attribute: the string to index, and its numeric form when the + * attribute has one (used for range queries and numeric sorting). + * + *

The dispatch is on the attribute's declared {@link SearchFieldType}, narrowed by a + * pattern match on the class that supplies the value. A type declaring {@code DATE} or + * {@code NUMBER} without extending {@code DateAttribute}/{@code NumberAttribute} is indexed as text + * rather than failing: the declaration says how the value should be queried, not where it is read + * from.

+ * + *

The formatting stays this engine's own, deliberately. Lucene needs a minute-resolution + * date string and a {@code long} for the numeric field, whereas + * {@link AttributeInterface#getSearchFieldValue()} hands back the shape the per-attribute field + * takes ({@code Date}, {@code Integer}), which is not what these two Lucene fields want - and whose + * {@code int} narrowing would truncate a number that does not fit 32 bits.

+ */ protected Object[] extractValuesToIndex(AttributeInterface attribute) { Object[] values = new Object[2]; String valueToIndex = null; Long number = null; - if (attribute instanceof DateAttribute) { - Date date = ((DateAttribute) attribute).getDate(); + SearchFieldType searchFieldType = attribute.getSearchFieldType(); + if (SearchFieldType.DATE == searchFieldType && attribute instanceof DateAttribute dateAttribute) { + Date date = dateAttribute.getDate(); number = (null != date) ? date.getTime() : null; valueToIndex = (null != number) ? DateTools.timeToString(number, DateTools.Resolution.MINUTE) : valueToIndex; - } else if (attribute instanceof NumberAttribute) { - BigDecimal value = ((NumberAttribute) attribute).getValue(); + } else if (SearchFieldType.NUMBER == searchFieldType + && attribute instanceof NumberAttribute numberAttribute) { + BigDecimal value = numberAttribute.getValue(); number = (null != value) ? value.longValue() : null; valueToIndex = (null != number) ? String.valueOf(number) : valueToIndex; } else { diff --git a/cms-plugin/src/main/resources/liquibase/jacms/changeSetPort.xml b/cms-plugin/src/main/resources/liquibase/jacms/changeSetPort.xml index 0b71a76292..42e6dd5aff 100644 --- a/cms-plugin/src/main/resources/liquibase/jacms/changeSetPort.xml +++ b/cms-plugin/src/main/resources/liquibase/jacms/changeSetPort.xml @@ -21,4 +21,8 @@ + + + + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml index 0cd93329f3..84f5deac20 100644 --- a/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml @@ -1928,4 +1928,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/20260803000000_jacms_widen_search_attrname.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/20260803000000_jacms_widen_search_attrname.xml new file mode 100644 index 0000000000..67ad8cc007 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/20260803000000_jacms_widen_search_attrname.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/20260807000000_jacms_boolean_userfilter_any_option.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/20260807000000_jacms_boolean_userfilter_any_option.xml new file mode 100644 index 0000000000..f0613407d3 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/20260807000000_jacms_boolean_userfilter_any_option.xml @@ -0,0 +1,78 @@ + + + + + + + SELECT COUNT(*) FROM localstrings WHERE keycode = 'ANY' + + + + + + + + + + + + + + + + + + + + code='jacms_content_viewer_list_userfilter_ent_Boolean' + + + + + + code='jacms_content_viewer_list_userfilter_ent_CheckBox' + + + + + + code='jacms_content_viewer_list_userfilter_ent_ThreeSt' + + + + + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_12_2.ftl b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_12_2.ftl new file mode 100644 index 0000000000..12686caac0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_12_2.ftl @@ -0,0 +1,24 @@ +<#assign wp=JspTaglibs["/aps-core"]> +<#assign formFieldNameVar = userFilterOptionVar.formFieldNames[0] > +<#assign formFieldValue = userFilterOptionVar.getFormFieldValue(formFieldNameVar) > +<#assign i18n_Attribute_Key = userFilterOptionVar.attribute.name > +
+<@wp.i18n key="${i18n_Attribute_Key}" /> +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_14_2.ftl b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_14_2.ftl new file mode 100644 index 0000000000..12686caac0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_14_2.ftl @@ -0,0 +1,24 @@ +<#assign wp=JspTaglibs["/aps-core"]> +<#assign formFieldNameVar = userFilterOptionVar.formFieldNames[0] > +<#assign formFieldValue = userFilterOptionVar.getFormFieldValue(formFieldNameVar) > +<#assign i18n_Attribute_Key = userFilterOptionVar.attribute.name > +
+<@wp.i18n key="${i18n_Attribute_Key}" /> +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_15_2.ftl b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_15_2.ftl new file mode 100644 index 0000000000..928c7a4e44 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/production/guifragment_15_2.ftl @@ -0,0 +1,24 @@ +<#assign wp=JspTaglibs["/aps-core"]> +<#assign formFieldNameVar = userFilterOptionVar.formFieldNames[0] > +<#assign formFieldValue = userFilterOptionVar.getFormFieldValue(formFieldNameVar) > +<#assign i18n_Attribute_Key = userFilterOptionVar.attribute.name > +
+<@wp.i18n key="${i18n_Attribute_Key}" /> +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml new file mode 100644 index 0000000000..42e41b3670 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml @@ -0,0 +1,26 @@ + + + Flag on + + + + + Flag on + + + true + + + true + + + + true + + + true + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml new file mode 100644 index 0000000000..29a98982b0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml @@ -0,0 +1,26 @@ + + + Flag off + + + + + Flag off + + + false + + + false + + + + false + + + false + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml new file mode 100644 index 0000000000..87a48cacd0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml @@ -0,0 +1,26 @@ + + + Mixed + + + + + Mixed + + + true + + + false + + + + false + + + true + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml new file mode 100644 index 0000000000..30e3c102e1 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml @@ -0,0 +1,22 @@ + + + Composite only + + + + + Composite only + + + false + + + + + true + + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml index 5905827856..09bab16fe5 100644 --- a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml @@ -286,4 +286,21 @@ + + + + + jacms:title + + + + + + + + + + + + diff --git a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-Boolean.jsp b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-Boolean.jsp index 176bc5800b..298a116d2c 100644 --- a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-Boolean.jsp +++ b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-Boolean.jsp @@ -1,15 +1,29 @@ <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="wp" uri="/aps-core" %> +<%-- + Mirrors the 'jacms_content_viewer_list_userfilter_ent_Boolean' guifragment + (clob/production/guifragment_12_2.ftl) and must stay in step with it: the Freemarker fragment is + used when present, this JSP is the fallback. + + One radio group, "Any" first and selected by default. The separate IGNORE checkbox was dropped: + it existed only because a radio cannot be deselected once chosen, and a real "Any" option makes + it redundant. Its include also carried the hidden '_control' field, which no Java reads. +--%> +
-
+
+ +
-
\ No newline at end of file + diff --git a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-CheckBox.jsp b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-CheckBox.jsp index ad7d57b802..6b35f93a3e 100644 --- a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-CheckBox.jsp +++ b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-CheckBox.jsp @@ -1,20 +1,38 @@ <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="wp" uri="/aps-core" %> +<%-- + Mirrors the 'jacms_content_viewer_list_userfilter_ent_CheckBox' guifragment + (clob/production/guifragment_14_2.ftl) and must stay in step with it: the Freemarker fragment is + used when present, this JSP is the fallback. + + This was a single "Yes" checkbox, so it could only ever ask for true. It is now the same + Any/Yes/No group as the Boolean filter - filtering for false is newly possible. +--%> +
-
-
+
+
+
+ +
-
\ No newline at end of file + diff --git a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-ThreeState.jsp b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-ThreeState.jsp index dc65392478..11fb55a169 100644 --- a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-ThreeState.jsp +++ b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/aps/jsp/widgets/inc/userFilter-module-entity-ThreeState.jsp @@ -1,26 +1,39 @@ <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="wp" uri="/aps-core" %> +<%-- + Mirrors the 'jacms_content_viewer_list_userfilter_ent_ThreeSt' guifragment + (clob/production/guifragment_15_2.ftl) and must stay in step with it: the Freemarker fragment is + used when present, this JSP is the fallback. + + The BOTH radio meant "do not filter", which no user reads it as; it is replaced by "Any". A + stored "both" value still renders as Any selected, so a bookmarked or back-button submission + keeps its meaning. + + No "Not set" option yet: an unset ThreeState writes no search row at all + (ThreeStateAttribute.addSearchInfo() returns false when unset), so it needs a nullOption filter + rather than a value filter - a Java change as well. Tracked separately. +--%> +
-
+ -
diff --git a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp index cc915bb47f..1f91c7c1bd 100644 --- a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp +++ b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp @@ -53,7 +53,7 @@

-
@@ -61,7 +61,7 @@
-
- + @@ -103,8 +103,8 @@
_textFieldName -