diff --git a/.github/test-and-scan.sh b/.github/test-and-scan.sh
index 8798efee22..6555d4dd9f 100755
--- a/.github/test-and-scan.sh
+++ b/.github/test-and-scan.sh
@@ -40,7 +40,10 @@ _mvn_verify() {
echo "~> Running mvn verify with options: $*"
fi
- mvn -B verify "$@"
+ # -fae + -Dmaven.test.failure.ignore=true: run the whole reactor and do NOT abort on a test
+ # failure, so the appended sonar:sonar goal always executes and SonarCloud re-analyzes on every
+ # commit (otherwise a single failing/flaky test stops the build before the scan runs).
+ mvn -B -fae -Dmaven.test.failure.ignore=true verify "$@"
}
_mvn_verify $OPT1 $OPT2 $OPT3 \
@@ -50,4 +53,31 @@ _mvn_verify $OPT1 $OPT2 $OPT3 \
RV="$?"
.github/github-tools/mvn.test.report.generate
-exit "$RV"
+
+# ---------------------------------------------------------------------------
+# Pipeline gate (kept SEPARATE from the scan).
+#
+# The mvn run above uses -Dmaven.test.failure.ignore=true so a failing/flaky
+# test can never stop the reactor before sonar:sonar -> the scan is ALWAYS
+# submitted on every push. We therefore re-enforce the test gate here:
+# (a) a non-zero mvn exit = a NON-test failure (compilation, sonar, plugin) -> fail;
+# (b) otherwise scan the surefire/failsafe XML reports and fail on any real
+# failure/error. Flaky tests that passed on retry are recorded as
+#
-
+
+
+
-
+
+
+
<composite>_<boolean> to avoid name collisions. {@code CheckBoxAttribute}
+ * and {@code ThreeStateAttribute} are excluded, and a boolean reached through a List/Monolist keeps
+ * the legacy plain-name behaviour (its path is not built).
+ * @param id the entity id.
+ * @param attribute the attribute to process.
+ * @param path the composite name path accumulated so far ('_'-joined), or null when at top level.
+ * @param listAncestor true when a List/Monolist is on the ancestry chain (disables path building).
+ * @param stat the batch statement to fill.
+ * @throws Throwable in case of error.
+ */
+ private void addAttributeSearchRecord(String id, AttributeInterface attribute, String path,
+ boolean listAncestor, PreparedStatement stat) throws Throwable {
+ if (attribute.isSimple()) {
+ this.addSimpleAttributeSearchRecord(id, attribute, path, listAncestor, stat);
+ } else {
+ this.descendComplexAttributeSearchRecords(id, attribute, path, listAncestor, stat);
+ }
+ }
+
+ private void addSimpleAttributeSearchRecord(String id, AttributeInterface attribute, String path,
+ boolean listAncestor, PreparedStatement stat) throws SQLException {
+ if (!attribute.isSearchable()) {
+ return;
+ }
+ ListA boolean nested in a Composite is indexed under the path key {@code
The write side ({@code AbstractEntityDAO.addEntitySearchRecord}) and the filter-key resolution + * ({@code EntitySearchFilter.getInstance}) share this class so that the key produced by the writer is + * exactly the key the reader resolves. It also gates {@code CompositeAttribute}'s decision to preserve + * the {@code searchable} flag on a composite child.
+ * + * @author Entando + */ +public final class NestedBooleanSearchSupport { + + private static final EntLogger logger = EntLogFactory.getSanitizedLogger(NestedBooleanSearchSupport.class); + + /** Separator used to render a nested attribute's hierarchy for humans (never occurs in a name). */ + public static final String LABEL_SEPARATOR = " > "; + + private NestedBooleanSearchSupport() { + // utility class + } + + /** + * Visitor invoked once per attribute a search form should offer. {@code keyPath} is the machine + * key (segment names joined by '_' - the DB {@code attrname} / Solr field / form field key); + * {@code labelPath} is the human hierarchy (segment names joined by {@link #LABEL_SEPARATOR}) built + * from the real tree boundaries, so it is correct even when a name itself contains '_'. + */ + @FunctionalInterface + private interface SearchableVisitor { + void visit(AttributeInterface attribute, String keyPath, String labelPath, boolean topLevel); + } + + /** + * Whether the given attribute is a boolean-like attribute eligible for nested (path-based) DB + * indexing, i.e. a {@link BooleanAttribute} or one of its subclasses (CheckBox, ThreeState). + * @param attribute the attribute to test. + * @return true if the attribute is boolean-like. + */ + public static boolean isIndexableNestedBoolean(AttributeInterface attribute) { + return attribute instanceof BooleanAttribute; + } + + /** + * Resolve a Composite-nested boolean attribute from its path key {@codeKept in one place so the schema side ({@code SolrFieldsChecker}), the document side + * ({@code IndexerDAO}) and the content-type settings report ({@code SolrSearchEngineManager}) build + * identical field names and apply the same "is this boolean indexable" decision. Any divergence + * would create fields the indexer never populates, or make the validity check loop forever.
+ * + *List and Monolist attributes are intentionally out of scope: booleans reached through a list + * are never indexed as per-attribute fields.
+ * + *Public (rather than package-private) because {@code ContentTypeSettings}, which needs the + * same type/value dispatch, lives in the {@code .model} sub-package.
+ */ +public final class SolrComplexAttributes { + + private SolrComplexAttributes() { + } + + /** + * A boolean-like attribute ({@code BooleanAttribute} or a subclass — {@code CheckBoxAttribute}, + * {@code ThreeStateAttribute}) nested in a Composite is indexed when its {@code searchable} flag, + * inherited from the content type, is set. This mirrors how top-level boolean-like attributes are + * gated, so nested and top-level behave uniformly. + */ + static boolean isIndexableCompositeBoolean(AttributeInterface attribute) { + return attribute instanceof BooleanAttribute && attribute.isSearchable(); + } + + /** + * The Solr field type for a boolean-like attribute. {@code ThreeStateAttribute} must be tested + * before {@code BooleanAttribute} (it is a subclass): its third "uninitialized" state cannot be + * represented in Solr's two-valued {@code BoolField}, so it is indexed as a non-analyzed {@code + * string} field with the literal values {@code true}/{@code false}/{@code none}; plain {@code + * BooleanAttribute}/{@code CheckBoxAttribute} are indexed as Solr {@code boolean}. + */ + public static String solrType(AttributeInterface attribute) { + if (attribute instanceof ThreeStateAttribute) { + return SolrFields.TYPE_STRING; + } + return SolrFields.TYPE_BOOLEAN; + } + + /** + * The value to write for a boolean-like attribute. {@code ThreeStateAttribute}: {@code null} + * (uninitialized) becomes the literal {@code "none"}, otherwise the lowercase string literal + * {@code "true"}/{@code "false"}. Plain {@code BooleanAttribute}/{@code CheckBoxAttribute}: + * {@code getValue()} already coerces {@code null} to {@code false}. + */ + public static Object solrValue(AttributeInterface attribute) { + if (attribute instanceof ThreeStateAttribute threeStateAttribute) { + Boolean value = threeStateAttribute.getValue(); + return (null == value) ? "none" : value.toString(); + } + return ((BooleanAttribute) attribute).getValue(); + } + + /** + * Extends a composite name path with a child segment. The resulting field name is + * "<lang>_<path>" (built by the callers), e.g. "en_complexAttrName_boolAttrName" for the boolean + * "boolAttrName" nested in the composite "complexAttrName". + */ + static String appendPath(String namePrefix, String name) { + return namePrefix + "_" + name; + } +} diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java index 74a81f268b..9ddd9a5677 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java @@ -4,9 +4,11 @@ import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_NAME; import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_TYPE; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute; import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface; import com.agiletec.aps.system.services.lang.Lang; @@ -95,33 +97,74 @@ private void checkLangFields() { private void checkAttribute(AttributeInterface attribute, Lang lang) { attribute.setRenderingLang(lang.getCode()); - if (attribute instanceof IndexableAttributeInterface - || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute) - && attribute.isSearchable())) { - String type; - if (attribute instanceof DateAttribute) { - type = SolrFields.TYPE_PDATES; - } else if (attribute instanceof NumberAttribute) { - type = SolrFields.TYPE_PLONGS; - } else if (attribute instanceof BooleanAttribute) { - type = SolrFields.TYPE_BOOLEAN; - } else { - type = SolrFields.TYPE_TEXT_GEN_SORT; - } - String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName(); - fieldName = fieldName.replace(":", "_"); + if (!attribute.isSimple()) { + this.checkComplexAttributeChildren(attribute, lang, attribute.getName()); + return; + } + if (this.isIndexableAttributeField(attribute)) { + String type = this.getFieldType(attribute); + String fieldName = this.buildFieldName(lang, attribute.getName()); this.checkField(fieldName, type); if (null == attribute.getRoles()) { return; } for (String role : attribute.getRoles()) { - String roleFieldName = lang.getCode().toLowerCase() + "_" + role; - roleFieldName = roleFieldName.replace(":", "_"); + String roleFieldName = this.buildFieldName(lang, role); this.checkField(roleFieldName, type); } } } + private boolean isIndexableAttributeField(AttributeInterface attribute) { + return attribute instanceof IndexableAttributeInterface + || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute + || attribute instanceof BooleanAttribute) && attribute.isSearchable()); + } + + private String getFieldType(AttributeInterface attribute) { + if (attribute instanceof DateAttribute) { + return SolrFields.TYPE_PDATES; + } else if (attribute instanceof NumberAttribute) { + return SolrFields.TYPE_PLONGS; + } else if (attribute instanceof BooleanAttribute) { + // Covers ThreeStateAttribute too (subclass): SolrComplexAttributes.solrType tests it + // first internally and returns TYPE_STRING for it, TYPE_BOOLEAN otherwise. + return SolrComplexAttributes.solrType(attribute); + } else { + return SolrFields.TYPE_TEXT_GEN_SORT; + } + } + + private String buildFieldName(Lang lang, String name) { + return (lang.getCode().toLowerCase() + "_" + name).replace(":", "_"); + } + + // Nested booleans only, Composite attributes only: List/Monolist attributes are excluded, and + // Date/Number/Text children remain full-text-only (unchanged, matching the baseline Lucene + // engine). The field name carries the full composite path, e.g. "en_complexAttrName_boolAttrName". + private void checkComplexAttributeChildren(AttributeInterface attribute, Lang lang, String namePrefix) { + if (attribute instanceof AbstractComplexAttribute complexAttribute && !(attribute instanceof ListAttributeInterface)) { + for (AttributeInterface child : complexAttribute.getAttributes()) { + this.checkNestedAttribute(child, lang, namePrefix); + } + } + } + + private void checkNestedAttribute(AttributeInterface attribute, Lang lang, String namePrefix) { + attribute.setRenderingLang(lang.getCode()); + String path = SolrComplexAttributes.appendPath(namePrefix, attribute.getName()); + if (!attribute.isSimple()) { + this.checkComplexAttributeChildren(attribute, lang, path); + return; + } + if (SolrComplexAttributes.isIndexableCompositeBoolean(attribute)) { + String fieldName = this.buildFieldName(lang, path); + // Single-valued: a Composite occurs at most once per document per lang (Monolist and + // Monolist-of-Composite are excluded above), so the nested field is never repeated. + this.checkField(fieldName, SolrComplexAttributes.solrType(attribute), false); + } + } + private void checkField(String fieldName, String type) { this.checkField(fieldName, type, false); } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java index 1cc685429c..1afad65234 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java @@ -19,7 +19,9 @@ import com.agiletec.aps.system.common.entity.event.EntityTypesChangingObserver; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.SmallEntityType; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface; import com.agiletec.aps.system.services.lang.ILangManager; import com.agiletec.aps.system.services.lang.Lang; import com.agiletec.aps.util.ApsTenantApplicationUtils; @@ -58,6 +60,10 @@ * @author E.Santoboni */ @Slf4j +// NOTE: java:S2143 ("use the java.time API") is intentionally suppressed. Legacy java.util.Date is +// used only to stamp a reload timestamp (new Date()); java.time migration is out of scope for +// ESB-1133 (boolean search) and tracked separately. +@SuppressWarnings("java:S2143") public class SolrSearchEngineManager extends SearchEngineManager implements ISolrSearchEngineManager, PublicContentChangedObserver, EntityTypesChangingObserver, InitializingBean { @@ -173,17 +179,15 @@ private List