From a5f8697f395c7a5a4f6e4e396d6d02de253366ef Mon Sep 17 00:00:00 2001 From: Yuxuan HU Date: Wed, 8 Jul 2026 16:47:33 +1000 Subject: [PATCH 1/3] scope.code searchable --- .../server/core/model/enumeration/CQLFields.java | 14 ++++++++++---- .../core/model/enumeration/StacSummeries.java | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/CQLFields.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/CQLFields.java index bbe37f7f..9df2b32c 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/CQLFields.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/CQLFields.java @@ -197,10 +197,16 @@ public enum CQLFields implements CQLFieldsInterface { null, null), scope( - StacSummeries.Scope.searchField, - StacSummeries.Scope.displayField, - null, - null), + StacSummeries.Scope.searchField, + StacSummeries.Scope.displayField, + (literal) -> NestedQuery.of(m -> m + .path(StacSummeries.Scope.displayField) + .query(q -> q + .term(t -> t + .field(StacSummeries.Scope.searchField) + .value(literal)))) + ._toQuery(), + null), score( CQLElasticSetting.score.getSetting(), CQLElasticSetting.score.getSetting(), diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/StacSummeries.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/StacSummeries.java index 3bc7717b..842ab4d6 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/StacSummeries.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/model/enumeration/StacSummeries.java @@ -16,7 +16,7 @@ public enum StacSummeries { Status("summaries.status", "summaries.status"), Statement("summaries.statement", "summaries.statement"), Credits("summaries.credits", "summaries.credits"), - Scope("summaries.scope", "summaries.scope"), + Scope("summaries.scope.code", "summaries.scope"), AiParameterVocabs("summaries.ai:parameter_vocabs", "summaries.ai:parameter_vocabs"), AiPlatformVocabs("summaries.ai:platform_vocabs", "summaries.ai:platform_vocabs") ; From 88a79ed7dd92befd32f61134dbf8ca7b427a9730 Mon Sep 17 00:00:00 2001 From: Yuxuan HU Date: Fri, 10 Jul 2026 13:37:11 +1000 Subject: [PATCH 2/3] clean duplicated code --- .../server/core/service/ElasticSearch.java | 324 +++++++----------- 1 file changed, 127 insertions(+), 197 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java index b73c2f47..245540b8 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java @@ -257,39 +257,29 @@ public ElasticSearchBase.SearchResult searchAllCollections( return searchCollectionsByIds(null, Boolean.FALSE, sortBy); } - /** - * Build SearchRequest for searchByParameters and explainByParameters - * */ - protected Supplier buildParameterSearchRequestSupplier( - List keywords, - String cql, - List properties, - String sortBy, - CQLCrsType coor) throws CQLException { - - if ((keywords == null || keywords.isEmpty()) && cql == null) { - List queries = new ArrayList<>(); - queries.add(MatchQuery.of(m -> m - .field(StacType.searchField) - .query(StacType.Collection.value))._toQuery()); + * Holds the reusable pieces of a parameter/CQL search, computed once and consumed by + * both searchByParameters (via searchCollectionBy) and the explain endpoints + * (via buildParameterSearchRequestSupplier -> buildCollectionSearchRequestSupplier). + */ + protected record ParameterSearchContext( + List should, + List filters, + List searchAfter, + List sortOptions, + Double score, + Long maxSize) {} - return buildCollectionSearchRequestSupplier( - queries, - null, - null, - null, - null, - createSortOptions(sortBy, CQLFields.class), - null, - null); - } + protected ParameterSearchContext createParameterSearchContext( + List keywords, String cql, String sortBy, CQLCrsType coor) throws CQLException { List should = null; if (keywords != null && !keywords.isEmpty()) { should = new ArrayList<>(); - for (String t : keywords) { + // If user's input starts and ends with a quote " (and is non-empty), treat it as an + // exact-term search against the original title/description instead of the fuzzy fields. + // All other fields are searched with the same term regardless, as they do not use fuzzy matching. boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; String term = isExact ? t.substring(1, t.length() - 1) : t; @@ -301,7 +291,6 @@ protected Supplier buildParameterSearchRequestSupplier( should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); } - should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); @@ -316,37 +305,70 @@ protected Supplier buildParameterSearchRequestSupplier( List filters = new ArrayList<>(); CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); - if (cql != null) { - Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); - if (filter instanceof QueryHandler handler) { - if (handler.getErrors() == null || handler.getErrors().isEmpty()) { - if (handler.getQuery() != null) { - filters = List.of(handler.getQuery()); + try { + Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); + if (filter instanceof QueryHandler handler) { + if (handler.getErrors() == null || handler.getErrors().isEmpty()) { + if (handler.getQuery() != null) { + filters = List.of(handler.getQuery()); + } + } + else { + throw new IllegalArgumentException( + "ECQL Parse Error", + handler.getErrors() + .stream() + .reduce(null, (e1, e2) -> { + if (e1 == null) return e2; + e1.addSuppressed(e2); + return e1; + })); } } - else { - throw new IllegalArgumentException("ECQL Parse Error"); - } + } + catch (CQLException ce) { + log.error("Error parsing ECQL", ce); + throw ce; } } + // Get the page size after parsing Map setting = factory.getQuerySetting(); + Long maxSize = null; + try { + if (setting.get(CQLElasticSetting.page_size) != null + && !setting.get(CQLElasticSetting.page_size).isBlank()) { + maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); + } + } + catch (NumberFormatException pe) { + // Nothing to do as accept null as default + } - Long maxSize = setting.get(CQLElasticSetting.page_size) == null - || setting.get(CQLElasticSetting.page_size).isBlank() - ? null - : Long.parseLong(setting.get(CQLElasticSetting.page_size)); - - Double score = setting.get(CQLElasticSetting.score) == null - || setting.get(CQLElasticSetting.score).isBlank() - ? null - : Double.parseDouble(setting.get(CQLElasticSetting.score)); + // Get the score after parsing + // TODO: !! It is not good to set score due to fact that the text search include match on filter + // in case of text where filter is the only match, the score will become null (only fuzzy match have score) + // then if you set a score, you have nothing match. In the future, this score should be removed if we + // do not encounter a good use case. !! + Double score = null; + try { + if (setting.get(CQLElasticSetting.score) != null + && !setting.get(CQLElasticSetting.score).isBlank()) { + score = Double.parseDouble(setting.get(CQLElasticSetting.score)); + } + } + catch (Exception e) { + log.warn("Error parsing score assume null", e); + // OK to ignore as accept null as the value + } + // Get the search after List searchAfter = null; if (setting.get(CQLElasticSetting.search_after) != null && !setting.get(CQLElasticSetting.search_after).isBlank()) { - searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after).split(searchAfterSplitRegex)) + searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) + .split(searchAfterSplitRegex)) .filter(v -> !v.isBlank()) .map(String::trim) .map(ElasticSearch::toFieldValue) @@ -354,172 +376,80 @@ protected Supplier buildParameterSearchRequestSupplier( } List sortOptions = createSortOptions(sortBy, CQLFields.class); - + // When the filter searches curated vocab fields, prepend presence-based priority sort keys + // so matching human-curated records rank above AI-generated fallback records. This is the + // first sort key; existing -score,-rank ordering is preserved within each tier. if (factory.isParameterPrioritySort()) { - if (sortOptions == null) sortOptions = new ArrayList<>(); - sortOptions.add(0, CQLFields.parameter_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); + sortOptions = prependPrioritySort(sortOptions, CQLFields.parameter_vocabs); } - if (factory.isPlatformPrioritySort()) { - if (sortOptions == null) sortOptions = new ArrayList<>(); - sortOptions.add(0, CQLFields.platform_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); + sortOptions = prependPrioritySort(sortOptions, CQLFields.platform_vocabs); } + return new ParameterSearchContext(should, filters, searchAfter, sortOptions, score, maxSize); + } + + private List prependPrioritySort(List sortOptions, CQLFields field) { + if (sortOptions == null) { + sortOptions = new ArrayList<>(); + } + sortOptions.add(0, field.getSortBuilder().apply(SortOrder.Desc).build()); + return sortOptions; + } + + + + /** + * Build SearchRequest for searchByParameters and explainByParameters + */ + protected Supplier buildParameterSearchRequestSupplier( + List keywords, + String cql, + List properties, + String sortBy, + CQLCrsType coor) throws CQLException { + + if ((keywords == null || keywords.isEmpty()) && cql == null) { + List queries = new ArrayList<>(); + queries.add(MatchQuery.of(m -> m + .field(StacType.searchField) + .query(StacType.Collection.value))._toQuery()); + + return buildCollectionSearchRequestSupplier( + queries, null, null, null, null, + createSortOptions(sortBy, CQLFields.class), null, null); + } + + ParameterSearchContext ctx = createParameterSearchContext(keywords, cql, sortBy, coor); return buildCollectionSearchRequestSupplier( null, - should, - filters, + ctx.should(), + ctx.filters(), properties, - searchAfter, - sortOptions, - score, - maxSize); + ctx.searchAfter(), + ctx.sortOptions(), + ctx.score(), + ctx.maxSize()); } @Override - public ElasticSearchBase.SearchResult searchByParameters(List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { + public ElasticSearchBase.SearchResult searchByParameters( + List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { - if((keywords == null || keywords.isEmpty()) && cql == null) { + if ((keywords == null || keywords.isEmpty()) && cql == null) { return searchAllCollections(sortBy); } - else { - - List should = null; - if(keywords != null && !keywords.isEmpty()) { - should = new ArrayList<>(); - - for (String t : keywords) { - // If user's input (keywords) starts and ends with quote ", and the text is not empty - // treat the user intend to search with the exact term, - // instead of searching in fuzzy fields i.e., fuzzy_title and fuzzy_desc, - // search in the original title and description fields - // other fields are searched with the same term regardless of exact match or not, as they do not use fuzzy matching. - boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; - // If search text with double quote, remove quotes, - // otherwise keeps same - String term = isExact ? t.substring(1, t.length() - 1) : t; - - if (isExact) { - // Match phrase in original title and description, not use fuzzy fields - should.add(CQLFields.title.getPropertyEqualToQuery(term)); - should.add(CQLFields.description.getPropertyEqualToQuery(term)); - } - else { - should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); - should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); - } - should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); - should.add(CQLFields.id.getPropertyEqualToQuery(term)); - // Acronym match on the *.synonyms sub-fields, e.g. "SOOP" -> "ships of opportunity". - should.add(CQLFields.acronym_title.getPropertyEqualToQuery(term)); - should.add(CQLFields.acronym_desc.getPropertyEqualToQuery(term)); - // credit_contains uses match query by default, exact match is not applied here - should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); - } - } - - List filters = new ArrayList<>(); - - CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); - if(cql != null) { - try { - Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); - if(filter instanceof QueryHandler handler) { - if(handler.getErrors() == null || handler.getErrors().isEmpty()) { - if(handler.getQuery() != null) { - // There is no error during parsing - filters = List.of(handler.getQuery()); - } - } - else { - throw new IllegalArgumentException( - "ECQL Parse Error", - handler.getErrors() - .stream() - .reduce(null, (e1, e2) -> { - if (e1 == null) return e2; - e1.addSuppressed(e2); - return e1; - })); - } - } - } - catch(CQLException ce) { - log.error("Error parsing ECQL", ce); - throw ce; - } - } - // Get the page size after parsing - Map setting = factory.getQuerySetting(); - Long maxSize = null; - try { - if(setting.get(CQLElasticSetting.page_size) != null && - !setting.get(CQLElasticSetting.page_size).isBlank()) { - maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); - } - } - catch(NumberFormatException pe) { - // Nothing to do as except null as default - } - // Get the score after parsing - // TODO: !! It is not good to set score due to fact that the text search include match on filter - // in case of text where filter is the only match, the score will become null (only fuzzy match have score) - // then if you set a score, you have nothing match. In the future, this score should be removed if we - // do not encounter a good use case. !! - Double score = null; - try { - if (setting.get(CQLElasticSetting.score) != null && - !setting.get(CQLElasticSetting.score).isBlank()) { - score = Double.parseDouble(setting.get(CQLElasticSetting.score)); - } - } - catch(Exception e) { - log.warn("Error parsing score assume null", e); - // OK to ignore as accept null as the value - } - // Get the search after - List searchAfter = null; - if (setting.get(CQLElasticSetting.search_after) != null && - !setting.get(CQLElasticSetting.search_after).isBlank()) { - // Convert the regex separate string to List - searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) - .split(searchAfterSplitRegex)) - .filter(v -> !v.isBlank()) - .map(String::trim) - .map(ElasticSearch::toFieldValue) - .toList(); - } - - List sortOptions = createSortOptions(sortBy, CQLFields.class); - // When the filter searches curated vocab fields, prepend presence-based priority sort keys - // so matching human-curated records rank above AI-generated fallback records. This is - // the first sort key; existing -score,-rank ordering is preserved within each tier. - if (factory.isParameterPrioritySort()) { - if (sortOptions == null) { - sortOptions = new ArrayList<>(); - } - sortOptions.add(0, CQLFields.parameter_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); - } - if (factory.isPlatformPrioritySort()) { - if (sortOptions == null) { - sortOptions = new ArrayList<>(); - } - sortOptions.add(0, CQLFields.platform_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); - } - return searchCollectionBy( - null, - should, - filters, - properties, - searchAfter, - sortOptions, - score, - maxSize - ); - } + ParameterSearchContext ctx = createParameterSearchContext(keywords, cql, sortBy, coor); + return searchCollectionBy( + null, + ctx.should(), + ctx.filters(), + properties, + ctx.searchAfter(), + ctx.sortOptions(), + ctx.score(), + ctx.maxSize()); } @Override From 60c570cefd2849e020018902ebbe804e35451368 Mon Sep 17 00:00:00 2001 From: Yuxuan HU Date: Wed, 15 Jul 2026 11:07:05 +1000 Subject: [PATCH 3/3] Revert "clean duplicated code" This reverts commit 88a79ed7dd92befd32f61134dbf8ca7b427a9730. --- .../server/core/service/ElasticSearch.java | 324 +++++++++++------- 1 file changed, 197 insertions(+), 127 deletions(-) diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java index 245540b8..b73c2f47 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java @@ -257,29 +257,39 @@ public ElasticSearchBase.SearchResult searchAllCollections( return searchCollectionsByIds(null, Boolean.FALSE, sortBy); } + /** - * Holds the reusable pieces of a parameter/CQL search, computed once and consumed by - * both searchByParameters (via searchCollectionBy) and the explain endpoints - * (via buildParameterSearchRequestSupplier -> buildCollectionSearchRequestSupplier). - */ - protected record ParameterSearchContext( - List should, - List filters, - List searchAfter, - List sortOptions, - Double score, - Long maxSize) {} + * Build SearchRequest for searchByParameters and explainByParameters + * */ + protected Supplier buildParameterSearchRequestSupplier( + List keywords, + String cql, + List properties, + String sortBy, + CQLCrsType coor) throws CQLException { + + if ((keywords == null || keywords.isEmpty()) && cql == null) { + List queries = new ArrayList<>(); + queries.add(MatchQuery.of(m -> m + .field(StacType.searchField) + .query(StacType.Collection.value))._toQuery()); - protected ParameterSearchContext createParameterSearchContext( - List keywords, String cql, String sortBy, CQLCrsType coor) throws CQLException { + return buildCollectionSearchRequestSupplier( + queries, + null, + null, + null, + null, + createSortOptions(sortBy, CQLFields.class), + null, + null); + } List should = null; if (keywords != null && !keywords.isEmpty()) { should = new ArrayList<>(); + for (String t : keywords) { - // If user's input starts and ends with a quote " (and is non-empty), treat it as an - // exact-term search against the original title/description instead of the fuzzy fields. - // All other fields are searched with the same term regardless, as they do not use fuzzy matching. boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; String term = isExact ? t.substring(1, t.length() - 1) : t; @@ -291,6 +301,7 @@ protected ParameterSearchContext createParameterSearchContext( should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); } + should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); @@ -305,70 +316,37 @@ protected ParameterSearchContext createParameterSearchContext( List filters = new ArrayList<>(); CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); + if (cql != null) { - try { - Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); - if (filter instanceof QueryHandler handler) { - if (handler.getErrors() == null || handler.getErrors().isEmpty()) { - if (handler.getQuery() != null) { - filters = List.of(handler.getQuery()); - } - } - else { - throw new IllegalArgumentException( - "ECQL Parse Error", - handler.getErrors() - .stream() - .reduce(null, (e1, e2) -> { - if (e1 == null) return e2; - e1.addSuppressed(e2); - return e1; - })); + Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); + if (filter instanceof QueryHandler handler) { + if (handler.getErrors() == null || handler.getErrors().isEmpty()) { + if (handler.getQuery() != null) { + filters = List.of(handler.getQuery()); } } - } - catch (CQLException ce) { - log.error("Error parsing ECQL", ce); - throw ce; + else { + throw new IllegalArgumentException("ECQL Parse Error"); + } } } - // Get the page size after parsing Map setting = factory.getQuerySetting(); - Long maxSize = null; - try { - if (setting.get(CQLElasticSetting.page_size) != null - && !setting.get(CQLElasticSetting.page_size).isBlank()) { - maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); - } - } - catch (NumberFormatException pe) { - // Nothing to do as accept null as default - } - // Get the score after parsing - // TODO: !! It is not good to set score due to fact that the text search include match on filter - // in case of text where filter is the only match, the score will become null (only fuzzy match have score) - // then if you set a score, you have nothing match. In the future, this score should be removed if we - // do not encounter a good use case. !! - Double score = null; - try { - if (setting.get(CQLElasticSetting.score) != null - && !setting.get(CQLElasticSetting.score).isBlank()) { - score = Double.parseDouble(setting.get(CQLElasticSetting.score)); - } - } - catch (Exception e) { - log.warn("Error parsing score assume null", e); - // OK to ignore as accept null as the value - } + Long maxSize = setting.get(CQLElasticSetting.page_size) == null + || setting.get(CQLElasticSetting.page_size).isBlank() + ? null + : Long.parseLong(setting.get(CQLElasticSetting.page_size)); + + Double score = setting.get(CQLElasticSetting.score) == null + || setting.get(CQLElasticSetting.score).isBlank() + ? null + : Double.parseDouble(setting.get(CQLElasticSetting.score)); - // Get the search after List searchAfter = null; if (setting.get(CQLElasticSetting.search_after) != null && !setting.get(CQLElasticSetting.search_after).isBlank()) { - searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) - .split(searchAfterSplitRegex)) + searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after).split(searchAfterSplitRegex)) .filter(v -> !v.isBlank()) .map(String::trim) .map(ElasticSearch::toFieldValue) @@ -376,80 +354,172 @@ protected ParameterSearchContext createParameterSearchContext( } List sortOptions = createSortOptions(sortBy, CQLFields.class); - // When the filter searches curated vocab fields, prepend presence-based priority sort keys - // so matching human-curated records rank above AI-generated fallback records. This is the - // first sort key; existing -score,-rank ordering is preserved within each tier. - if (factory.isParameterPrioritySort()) { - sortOptions = prependPrioritySort(sortOptions, CQLFields.parameter_vocabs); - } - if (factory.isPlatformPrioritySort()) { - sortOptions = prependPrioritySort(sortOptions, CQLFields.platform_vocabs); - } - - return new ParameterSearchContext(should, filters, searchAfter, sortOptions, score, maxSize); - } - private List prependPrioritySort(List sortOptions, CQLFields field) { - if (sortOptions == null) { - sortOptions = new ArrayList<>(); + if (factory.isParameterPrioritySort()) { + if (sortOptions == null) sortOptions = new ArrayList<>(); + sortOptions.add(0, CQLFields.parameter_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); } - sortOptions.add(0, field.getSortBuilder().apply(SortOrder.Desc).build()); - return sortOptions; - } - - - /** - * Build SearchRequest for searchByParameters and explainByParameters - */ - protected Supplier buildParameterSearchRequestSupplier( - List keywords, - String cql, - List properties, - String sortBy, - CQLCrsType coor) throws CQLException { - - if ((keywords == null || keywords.isEmpty()) && cql == null) { - List queries = new ArrayList<>(); - queries.add(MatchQuery.of(m -> m - .field(StacType.searchField) - .query(StacType.Collection.value))._toQuery()); - - return buildCollectionSearchRequestSupplier( - queries, null, null, null, null, - createSortOptions(sortBy, CQLFields.class), null, null); + if (factory.isPlatformPrioritySort()) { + if (sortOptions == null) sortOptions = new ArrayList<>(); + sortOptions.add(0, CQLFields.platform_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); } - ParameterSearchContext ctx = createParameterSearchContext(keywords, cql, sortBy, coor); return buildCollectionSearchRequestSupplier( null, - ctx.should(), - ctx.filters(), + should, + filters, properties, - ctx.searchAfter(), - ctx.sortOptions(), - ctx.score(), - ctx.maxSize()); + searchAfter, + sortOptions, + score, + maxSize); } @Override - public ElasticSearchBase.SearchResult searchByParameters( - List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { + public ElasticSearchBase.SearchResult searchByParameters(List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { - if ((keywords == null || keywords.isEmpty()) && cql == null) { + if((keywords == null || keywords.isEmpty()) && cql == null) { return searchAllCollections(sortBy); } + else { + + List should = null; + if(keywords != null && !keywords.isEmpty()) { + should = new ArrayList<>(); + + for (String t : keywords) { + // If user's input (keywords) starts and ends with quote ", and the text is not empty + // treat the user intend to search with the exact term, + // instead of searching in fuzzy fields i.e., fuzzy_title and fuzzy_desc, + // search in the original title and description fields + // other fields are searched with the same term regardless of exact match or not, as they do not use fuzzy matching. + boolean isExact = t.startsWith("\"") && t.endsWith("\"") && t.length() > 2; + // If search text with double quote, remove quotes, + // otherwise keeps same + String term = isExact ? t.substring(1, t.length() - 1) : t; + + if (isExact) { + // Match phrase in original title and description, not use fuzzy fields + should.add(CQLFields.title.getPropertyEqualToQuery(term)); + should.add(CQLFields.description.getPropertyEqualToQuery(term)); + } + else { + should.add(CQLFields.fuzzy_title.getPropertyEqualToQuery(term)); + should.add(CQLFields.fuzzy_desc.getPropertyEqualToQuery(term)); + } + should.add(CQLFields.parameter_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.organisation_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.platform_vocabs.getPropertyEqualToQuery(term)); + should.add(CQLFields.id.getPropertyEqualToQuery(term)); + // Acronym match on the *.synonyms sub-fields, e.g. "SOOP" -> "ships of opportunity". + should.add(CQLFields.acronym_title.getPropertyEqualToQuery(term)); + should.add(CQLFields.acronym_desc.getPropertyEqualToQuery(term)); + // credit_contains uses match query by default, exact match is not applied here + should.add(CQLFields.credit_contains.getPropertyEqualToQuery(term)); + } + } - ParameterSearchContext ctx = createParameterSearchContext(keywords, cql, sortBy, coor); - return searchCollectionBy( - null, - ctx.should(), - ctx.filters(), - properties, - ctx.searchAfter(), - ctx.sortOptions(), - ctx.score(), - ctx.maxSize()); + List filters = new ArrayList<>(); + + CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); + if(cql != null) { + try { + Filter filter = CompilerUtil.parseFilter(Language.ECQL, cql, factory); + if(filter instanceof QueryHandler handler) { + if(handler.getErrors() == null || handler.getErrors().isEmpty()) { + if(handler.getQuery() != null) { + // There is no error during parsing + filters = List.of(handler.getQuery()); + } + } + else { + throw new IllegalArgumentException( + "ECQL Parse Error", + handler.getErrors() + .stream() + .reduce(null, (e1, e2) -> { + if (e1 == null) return e2; + e1.addSuppressed(e2); + return e1; + })); + } + } + } + catch(CQLException ce) { + log.error("Error parsing ECQL", ce); + throw ce; + } + } + // Get the page size after parsing + Map setting = factory.getQuerySetting(); + Long maxSize = null; + try { + if(setting.get(CQLElasticSetting.page_size) != null && + !setting.get(CQLElasticSetting.page_size).isBlank()) { + maxSize = Long.parseLong(setting.get(CQLElasticSetting.page_size)); + } + } + catch(NumberFormatException pe) { + // Nothing to do as except null as default + } + // Get the score after parsing + // TODO: !! It is not good to set score due to fact that the text search include match on filter + // in case of text where filter is the only match, the score will become null (only fuzzy match have score) + // then if you set a score, you have nothing match. In the future, this score should be removed if we + // do not encounter a good use case. !! + Double score = null; + try { + if (setting.get(CQLElasticSetting.score) != null && + !setting.get(CQLElasticSetting.score).isBlank()) { + score = Double.parseDouble(setting.get(CQLElasticSetting.score)); + } + } + catch(Exception e) { + log.warn("Error parsing score assume null", e); + // OK to ignore as accept null as the value + } + // Get the search after + List searchAfter = null; + if (setting.get(CQLElasticSetting.search_after) != null && + !setting.get(CQLElasticSetting.search_after).isBlank()) { + // Convert the regex separate string to List + searchAfter = Arrays.stream(setting.get(CQLElasticSetting.search_after) + .split(searchAfterSplitRegex)) + .filter(v -> !v.isBlank()) + .map(String::trim) + .map(ElasticSearch::toFieldValue) + .toList(); + } + + List sortOptions = createSortOptions(sortBy, CQLFields.class); + // When the filter searches curated vocab fields, prepend presence-based priority sort keys + // so matching human-curated records rank above AI-generated fallback records. This is + // the first sort key; existing -score,-rank ordering is preserved within each tier. + if (factory.isParameterPrioritySort()) { + if (sortOptions == null) { + sortOptions = new ArrayList<>(); + } + sortOptions.add(0, CQLFields.parameter_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); + } + if (factory.isPlatformPrioritySort()) { + if (sortOptions == null) { + sortOptions = new ArrayList<>(); + } + sortOptions.add(0, CQLFields.platform_vocabs.getSortBuilder().apply(SortOrder.Desc).build()); + } + + return searchCollectionBy( + null, + should, + filters, + properties, + searchAfter, + sortOptions, + score, + maxSize + ); + } } @Override