content: /*
   This file is part of Airsonic.
  
   Airsonic is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.
  
   Airsonic 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 General Public License for more details.
  
   You should have received a copy of the GNU General Public License
   along with Airsonic.  If not, see <http://www.gnu.org/licenses/>.
  
   Copyright 2016 (C) Airsonic Authors
   Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
   */
  
  package org.airsonic.player.service.search;
  
  import org.airsonic.player.domain.MediaFile.MediaType;
  import org.airsonic.player.domain.MusicFolder;
  import org.airsonic.player.domain.RandomSearchCriteria;
  import org.airsonic.player.domain.SearchCriteria;
  import org.apache.lucene.analysis.Analyzer;
  import org.apache.lucene.analysis.TokenStream;
  import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
  import org.apache.lucene.document.IntPoint;
  import org.apache.lucene.index.Term;
  import org.apache.lucene.search.BooleanClause.Occur;
  import org.apache.lucene.search.BooleanQuery;
  import org.apache.lucene.search.BoostQuery;
  import org.apache.lucene.search.Query;
  import org.apache.lucene.search.TermQuery;
  import org.apache.lucene.search.WildcardQuery;
  import org.checkerframework.checker.nullness.qual.NonNull;
  import org.checkerframework.checker.nullness.qual.Nullable;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.stereotype.Component;
  
  import java.io.IOException;
  import java.util.ArrayList;
  import java.util.List;
  import java.util.function.BiFunction;
  import java.util.function.Function;
  
  import static org.springframework.util.ObjectUtils.isEmpty;
  
  /**
   * Factory class of Lucene Query.
   * This class is an extract of the functionality that was once part of SearchService.
   * It is for maintainability and verification.
   * Each corresponds to the SearchService method.
   * The API syntax for query generation depends on the lucene version.
   * verification with query grammar is possible.
   * On the other hand, the generated queries are relatively small by version.
   * Therefore, test cases of this class are useful for large version upgrades.
   **/
  @Component
  public class QueryFactory {
  
      private static final String ASTERISK = "*";
  
      @Autowired
      private AnalyzerFactory analyzerFactory;
  
      private final Function<MusicFolder, Query> toFolderIdQuery = (folder) -> {
          // Unanalyzed field
          return new TermQuery(new Term(FieldNames.FOLDER_ID, folder.getId().toString()));
      };
  
      private final Function<MusicFolder, Query> toFolderPathQuery = (folder) -> {
          // Unanalyzed field
          return new TermQuery(new Term(FieldNames.FOLDER, folder.getPath().getPath()));
      };
  
      /*
       *  XXX 3.x -> 8.x :
       *  "SpanOr" has been changed to "Or".
       *   - Path comparison is more appropriate with "Or".
       *   - If "SpanOr" is maintained, the DOC design needs to be changed.
       */
      private final BiFunction<@NonNull Boolean, @NonNull List<MusicFolder>, @NonNull Query> toFolderQuery = (
              isId3, folders) -> {
          BooleanQuery.Builder mfQuery = new BooleanQuery.Builder();
          folders.stream()
              .map(isId3 ? toFolderIdQuery : toFolderPathQuery)
              .forEach(t -> mfQuery.add(t, Occur.SHOULD));
          return mfQuery.build();
      };
  
      /*
       *  XXX 3.x -> 8.x :
       * In order to support wildcards,
       * MultiFieldQueryParser has been replaced by the following process.
       *
       *  - There is also an override of MultiFieldQueryParser, but it is known to be high cost.
       *  - MultiFieldQueryParser was created before Java API was modernized.
       *  - The spec of Parser has changed from time to time. Using parser does not reduce library update risk.
       *  - Self made parser process reduces one library dependency.
       *  - It is easy to make corrections later when changing the query to improve search accuracy.
       */
      private Query createMultiFieldWildQuery(@NonNull String[] fieldNames, @NonNull String queryString,
              @NonNull IndexType indexType)
              throws IOException {
  
          BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
  
          List<List<Query>> fieldsQuerys = new ArrayList<>();
          Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
  
          /* Wildcard applies to all tokens. **/
          for (String fieldName : fieldNames) {
              try (TokenStream stream = analyzer.tokenStream(fieldName, queryString)) {
                  stream.reset();
                  List<Query> fieldQuerys = new ArrayList<>();
                  while (stream.incrementToken()) {
                      String token = stream.getAttribute(CharTermAttribute.class).toString();
                      WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK)));
                      if (indexType.getBoosts().containsKey(fieldName)) {
                          fieldQuerys.add(new BoostQuery(wildcardQuery, indexType.getBoosts().get(fieldName)));
                      } else {
                          fieldQuerys.add(wildcardQuery);
                      }
                  }
                  fieldsQuerys.add(fieldQuerys);
              }
          }
  
          /* If Field's Tokenizer is different, token's length may not match. **/
          int maxTermLength = fieldsQuerys.stream()
                  .map(l -> l.size())
                  .max(Integer::compare).get();
  
          if (0 < fieldsQuerys.size()) {
              for (int i = 0; i < maxTermLength; i++) {
                  BooleanQuery.Builder fieldsQuery = new BooleanQuery.Builder();
                  for (List<Query> fieldQuerys : fieldsQuerys) {
                      if (i < fieldQuerys.size()) {
                          fieldsQuery.add(fieldQuerys.get(i), Occur.SHOULD);
                      }
                  }
                  mainQuery.add(fieldsQuery.build(), Occur.SHOULD);
              }
          }
  
          return mainQuery.build();
      }
  
      /*
       * XXX 3.x -> 8.x :
       * RangeQuery has been changed to not allow null.
       */
      private final BiFunction<@Nullable Integer, @Nullable Integer, @NonNull Query> toYearRangeQuery =
          (from, to) -> {
              return IntPoint.newRangeQuery(FieldNames.YEAR,
                  isEmpty(from) ? Integer.MIN_VALUE : from,
                  isEmpty(to) ? Integer.MAX_VALUE : to);
          };
  
      /**
       * Query generation expression extracted from
       * {@link org.airsonic.player.service.SearchService#search(SearchCriteria, List, IndexType)}.
       *
       * @param criteria criteria
       * @param musicFolders musicFolders
       * @param indexType {@link IndexType}
       * @return Query
       * @throws IOException When parsing of MultiFieldQueryParser fails
       */
      public Query search(SearchCriteria criteria, List<MusicFolder> musicFolders,
              IndexType indexType) throws IOException {
  
          BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
  
          Query multiFieldQuery = createMultiFieldWildQuery(indexType.getFields(), criteria.getQuery(), indexType);
          mainQuery.add(multiFieldQuery, Occur.MUST);
  
          boolean isId3 = indexType == IndexType.ALBUM_ID3 || indexType == IndexType.ARTIST_ID3;
          Query folderQuery = toFolderQuery.apply(isId3, musicFolders);
          mainQuery.add(folderQuery, Occur.MUST);
  
          return mainQuery.build();
      }
  
      /**
       * Query generation expression extracted from
       * {@link org.airsonic.player.service.SearchService#getRandomSongs(RandomSearchCriteria)}.
       */
      public Query getRandomSongs(RandomSearchCriteria criteria) throws IOException {
  
          BooleanQuery.Builder query = new BooleanQuery.Builder();
  
          Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
  
          // Unanalyzed field
          query.add(new TermQuery(new Term(FieldNames.MEDIA_TYPE, MediaType.MUSIC.name())), Occur.MUST);
  
          if (!isEmpty(criteria.getGenre())) {
  
              // Unanalyzed field, but performs filtering according to id3 tag parser.
              try (TokenStream stream = analyzer.tokenStream(FieldNames.GENRE, criteria.getGenre())) {
                  stream.reset();
                  if (stream.incrementToken()) {
                      String token = stream.getAttribute(CharTermAttribute.class).toString();
                      query.add(new TermQuery(new Term(FieldNames.GENRE, token)), Occur.MUST);
                  }
              }
          }
  
          if (!(isEmpty(criteria.getFromYear()) && isEmpty(criteria.getToYear()))) {
              query.add(toYearRangeQuery.apply(criteria.getFromYear(), criteria.getToYear()), Occur.MUST);
          }
  
          query.add(toFolderQuery.apply(false, criteria.getMusicFolders()), Occur.MUST);
  
          return query.build();
      }
  
      /**
       * Query generation expression extracted from
       * {@link org.airsonic.player.service.SearchService#searchByName( String, String, int, int, List, Class)}.
       *
       * @param fieldName {@link FieldNames}
       * @return Query
       * @throws IOException When parsing of QueryParser fails
       */
      public Query searchByName(String fieldName, String name) throws IOException {
  
          BooleanQuery.Builder mainQuery = new BooleanQuery.Builder();
  
          Analyzer analyzer = analyzerFactory.getQueryAnalyzer();
  
          try (TokenStream stream = analyzer.tokenStream(fieldName, name)) {
              stream.reset();
              stream.incrementToken();
  
              /*
               *  XXX 3.x -> 8.x :
               * In order to support wildcards,
               * QueryParser has been replaced by the following process.
               */
  
              /* Wildcards apply only to tail tokens **/
              while (true) {
                  String token = stream.getAttribute(CharTermAttribute.class).toString();
                  if (stream.incrementToken()) {
                      mainQuery.add(new TermQuery(new Term(fieldName, token)), Occur.SHOULD);
                  } else {
                      WildcardQuery wildcardQuery = new WildcardQuery(new Term(fieldName, token.concat(ASTERISK)));
                      mainQuery.add(wildcardQuery, Occur.SHOULD);
                      break;
                  }
              }
  
          }
  
          return mainQuery.build();
      }
  
      /**
       * Query generation expression extracted from
       * {@link org.airsonic.player.service.SearchService#getRandomAlbums(int, List)}.
       *
       * @param musicFolders musicFolders
       * @return Query
       */
      public Query getRandomAlbums(List<MusicFolder> musicFolders) {
          return new BooleanQuery.Builder()
                  .add(toFolderQuery.apply(false, musicFolders), Occur.SHOULD)
                  .build();
      }
  
      /**
       * Query generation expression extracted from
       * {@link org.airsonic.player.service.SearchService#getRandomAlbumsId3(int, List)}.
       *
       * @param musicFolders musicFolders
       * @return Query
       */
      public Query getRandomAlbumsId3(List<MusicFolder> musicFolders) {
          return new BooleanQuery.Builder()
                  .add(toFolderQuery.apply(true, musicFolders), Occur.SHOULD)
                  .build();
      }
  }
  