/** * This file is part of lavagna. * * lavagna 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. * * lavagna 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 lavagna. If not, see <http://www.gnu.org/licenses/>. */ package io.lavagna.service; import io.lavagna.model.*; import io.lavagna.query.SearchQuery; import io.lavagna.service.SearchFilter.FilterType; import io.lavagna.service.SearchFilter.SearchContext; import io.lavagna.service.SearchFilter.SearchFilterValue; import io.lavagna.service.SearchFilter.ValueType; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StreamUtils; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.sql.Clob; import java.sql.SQLException; import java.util.*; import static io.lavagna.service.SearchFilter.filter; import static io.lavagna.service.SearchFilter.filterByColumnDefinition; /** * Service for searching Cards using criterias defined using {@link SearchFilter}. */ @Service @Transactional(readOnly = true) public class SearchService { private static final Logger LOG = LogManager.getLogger(); private static final int CARDS_PER_PAGE = 50; private final NamedParameterJdbcTemplate jdbc; private final CardRepository cardRepository; private final CardService cardService; private final UserRepository userRepository; private final ProjectService projectService; private final BoardRepository boardRepository; private final SearchQuery queries; public SearchService(CardRepository cardRepository, CardService cardService, UserRepository userRepository, ProjectService projectService, BoardRepository boardRepository, NamedParameterJdbcTemplate jdbc, SearchQuery queries) { this.cardRepository = cardRepository; this.cardService = cardService; this.userRepository = userRepository; this.projectService = projectService; this.boardRepository = boardRepository; this.jdbc = jdbc; this.queries = queries; } private List<SearchFilter> filtersAsList(SearchFilter locationFilter, SearchFilter statusOpen, boolean excludeArchivedBoards) { return excludeArchivedBoards ? Arrays.asList(statusOpen, locationFilter, filter(FilterType.BOARD_STATUS, SearchFilter.ValueType.BOOLEAN, Boolean.FALSE)) : Arrays.asList(statusOpen, locationFilter); } public Map<ColumnDefinition, Integer> findTaksByColumnDefinition(Integer projectId, Integer boardId, boolean excludeArchivedBoards, UserWithPermission user) { SearchFilter locationFilter = filter(SearchFilter.FilterType.LOCATION, SearchFilter.ValueType.STRING, BoardColumn.BoardColumnLocation.BOARD.toString()); Map<ColumnDefinition, Integer> results = new EnumMap<>(ColumnDefinition.class); SearchFilter statusOpen = filterByColumnDefinition(ColumnDefinition.OPEN); SearchResults openTasks = find(filtersAsList(locationFilter, statusOpen, excludeArchivedBoards), projectId, boardId, user, 0); results.put(ColumnDefinition.OPEN, openTasks.getCount()); SearchFilter statusClosed = filterByColumnDefinition(ColumnDefinition.CLOSED); SearchResults closedTasks = find(filtersAsList(locationFilter, statusClosed, excludeArchivedBoards), projectId, boardId, user, 0); results.put(ColumnDefinition.CLOSED, closedTasks.getCount()); SearchFilter statusBacklog = filterByColumnDefinition(ColumnDefinition.BACKLOG); SearchResults backlogTasks = find(filtersAsList(locationFilter, statusBacklog, excludeArchivedBoards), projectId, boardId, user, 0); SearchFilter backlogFilterLocation = filter(SearchFilter.FilterType.LOCATION, SearchFilter.ValueType.STRING, BoardColumn.BoardColumnLocation.BACKLOG.toString()); SearchResults backlogSideBarTasks = find(filtersAsList(backlogFilterLocation, statusBacklog, excludeArchivedBoards), projectId, boardId, user, 0); results.put(ColumnDefinition.BACKLOG, backlogTasks.getCount() + backlogSideBarTasks.getCount()); SearchFilter statusDeferred = filterByColumnDefinition(ColumnDefinition.DEFERRED); SearchResults deferredTasks = find(filtersAsList(locationFilter, statusDeferred, excludeArchivedBoards), projectId, boardId, user, 0); results.put(ColumnDefinition.DEFERRED, deferredTasks.getCount()); return results; } public SearchResults find(List<SearchFilter> unmergedSearchFilter, Integer projectId, Integer boardId, UserWithPermission currentUser) { return find(unmergedSearchFilter, projectId, boardId, currentUser, false, 0); } public SearchResults find(List<SearchFilter> unmergedSearchFilter, Integer projectId, Integer boardId, UserWithPermission currentUser, int page) { return find(unmergedSearchFilter, projectId, boardId, currentUser, true, page); } private SearchResults find(List<SearchFilter> unmergedSearchFilter, Integer projectId, Integer boardId, UserWithPermission currentUser, boolean paginate, int page) { // if a user don't have access to the specified project id we skip the // whole search final boolean userHasNotProjectAccess = projectId != null && !currentUser.getBasePermissions().containsKey(Permission.READ) && !currentUser.projectsWithPermission(Permission.READ).contains( projectService.findById(projectId).getShortName()); final boolean userHasNoReadAccess = projectId == null && !currentUser.getBasePermissions().containsKey(Permission.READ) && currentUser.projectsWithPermission(Permission.READ).isEmpty(); final boolean noProjectIdForBoardId = projectId == null && boardId != null; final boolean boardIsntInProject = projectId != null && boardId != null && boardRepository.findBoardById(boardId).getProjectId() != projectId; if (userHasNotProjectAccess || userHasNoReadAccess || noProjectIdForBoardId || boardIsntInProject) { return new SearchResults(Collections.<CardFullWithCounts>emptyList(), 0, page, paginate ? CARDS_PER_PAGE : Integer.MAX_VALUE, paginate); } List<SearchFilter> searchFilters = mergeFreeTextFilters(unmergedSearchFilter); // List<Object> params = new ArrayList<>(); int filteringConditionsCount = 0; // List<String> usersOrCardToSearch = new ArrayList<>(); // fetch all possible user->id, card->id in the value types with string // (thus unknown use) for (SearchFilter searchFilter : searchFilters) { if (searchFilter.getValue() != null && searchFilter.getValue().getType() == ValueType.STRING) { usersOrCardToSearch.add(searchFilter.getValue().getValue().toString()); } } // Map<String, Integer> cardNameToId = cardRepository.findCardsIds(usersOrCardToSearch); Map<String, Integer> userNameToId = userRepository.findUsersId(usersOrCardToSearch); SearchContext searchContext = new SearchContext(currentUser, userNameToId, cardNameToId); // StringBuilder baseQuery = new StringBuilder(queries.findFirstFrom()).append("SELECT CARD_ID FROM ( "); // add filter conditions for (int i = 0; i < searchFilters.size(); i++) { SearchFilter searchFilter = searchFilters.get(i); String filterConditionQuery = searchFilter.getType().toBaseQuery(searchFilter, queries, params, searchContext); baseQuery.append("( ").append(filterConditionQuery).append(" ) "); if (i < searchFilters.size() - 1) { baseQuery.append(" UNION ALL "); } filteringConditionsCount++; } // /* AS CARD_IDS -> table alias for mysql */ baseQuery.append(" ) AS CARD_IDS GROUP BY CARD_ID HAVING COUNT(CARD_ID) = ?").append(queries.findSecond()); params.add(filteringConditionsCount); if (boardId != null) { baseQuery.append(queries.findThirdWhere()).append(queries.findFourthInBoardId()); params.add(boardId); } else if (projectId != null) { baseQuery.append(queries.findThirdWhere()).append(queries.findInFifthProjectId()); params.add(projectId); } else if (!currentUser.getBasePermissions().containsKey(Permission.READ)) { Set<Integer> projectsWithPermission = currentUser.projectsIdWithPermission(Permission.READ); baseQuery.append(queries.findThirdWhere()).append(queries.findSixthRestrictedReadAccess()).append(" (") .append(StringUtils.repeat("?", " , ", projectsWithPermission.size())).append(" ) "); params.addAll(projectsWithPermission); } String findCardsQuery = queries.findFirstSelect() + baseQuery.toString() + queries.findSeventhOrderBy(); if(paginate) { params.add(CARDS_PER_PAGE + 1);// limit params.add(page * CARDS_PER_PAGE);// offset findCardsQuery += queries.findEighthLimit(); } List<Integer> sr = jdbc.getJdbcOperations().queryForList(findCardsQuery, params.toArray(), Integer.class); // int count = sr.size(); if (paginate && page == 0 && sr.size() == (CARDS_PER_PAGE + 1) || page > 0) { String countCardsQuery = queries.findFirstSelectCount() + baseQuery.toString(); count = jdbc.getJdbcOperations().queryForObject(countCardsQuery, params.subList(0, params.size() - 2).toArray(), Integer.class); } // return new SearchResults(cardFullWithCounts(sr), count, page, paginate ? CARDS_PER_PAGE : Integer.MAX_VALUE, paginate); } private List<CardFullWithCounts> cardFullWithCounts(List<Integer> sr) { if (sr.isEmpty()) { return Collections.emptyList(); } // super ugly :( Map<Integer, Integer> idToPosition = new HashMap<>(); for (int i = 0; i < sr.size(); i++) { idToPosition.put(sr.get(i), i); } CardFull[] orderedCf = new CardFull[sr.size()]; // reorder: for (CardFull cf : cardRepository.findAllByIds(sr)) { orderedCf[idToPosition.get(cf.getId())] = cf; } // return cardService.fetchCardFull(Arrays.asList(orderedCf)); } private static List<SearchFilter> mergeFreeTextFilters(List<SearchFilter> unmergedSearchFilter) { List<SearchFilter> merged = new ArrayList<>(unmergedSearchFilter.size()); StringBuilder sb = new StringBuilder(); for (SearchFilter sf : unmergedSearchFilter) { if (sf.getType() != FilterType.FREETEXT) { merged.add(sf); } else { sb.append(" ").append(sf.getValue().getValue()); } } String freeText = sb.toString().trim(); if (freeText.length() > 0) { merged.add(new SearchFilter(FilterType.FREETEXT, null, new SearchFilterValue(ValueType.STRING, freeText))); } return merged; } // used by HSQLDB, obviously not optimized at all (as it's only for dev // purpose) public static boolean searchText(String data, String toSearch) { String[] wordsToSearch = toSearch.split("\\s+"); String lowerCasedData = data.toLowerCase(Locale.ENGLISH); for (String word : wordsToSearch) { if (!lowerCasedData.contains(word.toLowerCase(Locale.ENGLISH))) { return false; } } return true; } public static boolean searchTextClob(Clob data, String toSearch) { try (InputStream is = data.getAsciiStream()) { String res = StreamUtils.copyToString(is, StandardCharsets.UTF_8); return searchText(res, toSearch); } catch (IOException | SQLException e) { LOG.warn("error while reading clob", e); return false; } } }