Skip to content

Commit 4a249f5

Browse files
committed
Allow sorting in the API calls by path and date
Signed-off-by: Bernát Gábor <[email protected]>
1 parent 5c3018c commit 4a249f5

File tree

6 files changed

+208
-15
lines changed

6 files changed

+208
-15
lines changed

apiary.apib

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ The repository path is relative to source root.
643643
+ repository - repository path with native path separators (of the machine
644644
running the service) starting with path separator for which to return type
645645

646-
## Search [/search{?full,def,symbol,path,hist,type,projects,maxresults,start}]
646+
## Search [/search{?full,def,symbol,path,hist,type,projects,maxresults,start,sort}]
647647

648648
## return search results [GET]
649649

@@ -657,6 +657,15 @@ The repository path is relative to source root.
657657
+ projects (optional, string) - projects to search in
658658
+ maxresults (optional, string) - maximum number of documents whose hits will be returned (default 1000)
659659
+ start (optional, string) - start index from which to return results
660+
+ sort: relevancy (optional, enum[string])
661+
+ Enum
662+
+ relevancy
663+
+ fullpath
664+
+ lastmodtime
665+
+ Description: Sort order for results. Possible values:
666+
- `relevancy`: Sort by Lucene score (most relevant first).
667+
- `fullpath`: Sort by file path (alphabetical).
668+
- `lastmodtime`: Sort by last modification date (newest first).
660669

661670
+ Response 200 (application/json)
662671
+ Body

opengrok-indexer/src/main/java/org/opengrok/indexer/search/SearchEngine.java

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
import org.apache.lucene.search.IndexSearcher;
4949
import org.apache.lucene.search.Query;
5050
import org.apache.lucene.search.ScoreDoc;
51+
import org.apache.lucene.search.Sort;
52+
import org.apache.lucene.search.SortField;
53+
import org.apache.lucene.search.TopDocs;
5154
import org.apache.lucene.search.TopScoreDocCollectorManager;
5255
import org.apache.lucene.util.Version;
5356
import org.opengrok.indexer.analysis.AbstractAnalyzer;
@@ -66,6 +69,7 @@
6669
import org.opengrok.indexer.util.Statistics;
6770
import org.opengrok.indexer.util.TandemPath;
6871
import org.opengrok.indexer.web.Prefix;
72+
import org.opengrok.indexer.web.SortOrder;
6973

7074
/**
7175
* This is an encapsulation of the details on how to search in the index database.
@@ -114,6 +118,10 @@ public class SearchEngine {
114118
* Holds value of property type.
115119
*/
116120
private String type;
121+
/**
122+
* Holds value of property sort.
123+
*/
124+
private SortOrder sortOrder;
117125
/**
118126
* Holds value of property indexDatabase.
119127
*/
@@ -181,6 +189,10 @@ private void searchSingleDatabase(boolean paging) throws IOException {
181189
SuperIndexSearcher superIndexSearcher = RuntimeEnvironment.getInstance().getSuperIndexSearcher("");
182190
searcherList.add(superIndexSearcher);
183191
searcher = superIndexSearcher;
192+
// If a field-based sort is requested, collect all hits (disable paging optimization)
193+
if (sortOrder != SortOrder.RELEVANCY) {
194+
paging = false;
195+
}
184196
searchIndex(superIndexSearcher, paging);
185197
}
186198

@@ -205,20 +217,41 @@ private void searchMultiDatabase(List<Project> projectList, boolean paging) thro
205217
}
206218

207219
private void searchIndex(IndexSearcher searcher, boolean paging) throws IOException {
208-
collectorManager = new TopScoreDocCollectorManager(hitsPerPage * cachePages, Short.MAX_VALUE);
220+
Sort luceneSort = null;
221+
if (getSortOrder() == SortOrder.LASTMODIFIED) {
222+
luceneSort = new Sort(new SortField(QueryBuilder.DATE, SortField.Type.STRING, true));
223+
} else if (getSortOrder() == SortOrder.BY_PATH) {
224+
luceneSort = new Sort(new SortField(QueryBuilder.FULLPATH, SortField.Type.STRING));
225+
}
226+
227+
if (luceneSort == null) {
228+
collectorManager = new TopScoreDocCollectorManager(hitsPerPage * cachePages, Integer.MAX_VALUE);
229+
hits = searcher.search(query, collectorManager).scoreDocs;
230+
totalHits = searcher.count(query);
231+
} else {
232+
TopDocs top = searcher.search(query, hitsPerPage * cachePages, luceneSort);
233+
hits = top.scoreDocs;
234+
totalHits = (int) top.totalHits.value;
235+
}
236+
209237
Statistics stat = new Statistics();
210-
hits = searcher.search(query, collectorManager).scoreDocs;
211-
totalHits = searcher.count(query);
212238
stat.report(LOGGER, Level.FINEST, "search via SearchEngine done",
213239
"search.latency", new String[]{"category", "engine",
214240
"outcome", totalHits > 0 ? "success" : "empty"});
241+
215242
if (!paging && totalHits > hitsPerPage * cachePages) {
216-
collectorManager = new TopScoreDocCollectorManager(totalHits, Short.MAX_VALUE);
217-
hits = searcher.search(query, collectorManager).scoreDocs;
243+
if (luceneSort == null) {
244+
collectorManager = new TopScoreDocCollectorManager(totalHits, Integer.MAX_VALUE);
245+
hits = searcher.search(query, collectorManager).scoreDocs;
246+
} else {
247+
TopDocs top = searcher.search(query, totalHits, luceneSort);
248+
hits = top.scoreDocs;
249+
}
218250
stat.report(LOGGER, Level.FINEST, "FULL search via SearchEngine done",
219251
"search.latency", new String[]{"category", "engine",
220252
"outcome", totalHits > 0 ? "success" : "empty"});
221253
}
254+
222255
StoredFields storedFields = searcher.storedFields();
223256
for (ScoreDoc hit : hits) {
224257
int docId = hit.doc;
@@ -337,7 +370,6 @@ private int search(List<Project> projects, File root) {
337370
} catch (Exception e) {
338371
LOGGER.log(Level.WARNING, SEARCH_EXCEPTION_MSG, e);
339372
}
340-
341373
if (!docs.isEmpty()) {
342374
sourceContext = null;
343375
summarizer = null;
@@ -413,14 +445,27 @@ public void results(int start, int end, List<Hit> ret) {
413445

414446
// TODO check if below fits for if end=old hits.length, or it should include it
415447
if (end > hits.length && !allCollected) {
416-
//do the requery, we want more than 5 pages
417-
collectorManager = new TopScoreDocCollectorManager(totalHits, Short.MAX_VALUE);
448+
// do the requery to collect all hits (beyond cached pages)
449+
Sort luceneSort = null;
450+
if (getSortOrder() == SortOrder.LASTMODIFIED) {
451+
luceneSort = new Sort(new SortField(QueryBuilder.DATE, SortField.Type.STRING, true));
452+
} else if (getSortOrder() == SortOrder.BY_PATH) {
453+
luceneSort = new Sort(new SortField(QueryBuilder.FULLPATH, SortField.Type.STRING));
454+
}
455+
418456
try {
419-
hits = searcher.search(query, collectorManager).scoreDocs;
457+
if (luceneSort == null) {
458+
collectorManager = new TopScoreDocCollectorManager(totalHits, Integer.MAX_VALUE);
459+
hits = searcher.search(query, collectorManager).scoreDocs;
460+
} else {
461+
TopDocs top = searcher.search(query, totalHits, luceneSort);
462+
hits = top.scoreDocs;
463+
}
420464
} catch (Exception e) { // this exception should never be hit, since search() will hit this before
421465
LOGGER.log(
422466
Level.WARNING, SEARCH_EXCEPTION_MSG, e);
423467
}
468+
424469
StoredFields storedFields = null;
425470
try {
426471
storedFields = searcher.storedFields();
@@ -646,4 +691,22 @@ public String getType() {
646691
public void setType(String fileType) {
647692
this.type = fileType;
648693
}
694+
695+
/**
696+
* Getter for property sort.
697+
*
698+
* @return Value of property sortOrder.
699+
*/
700+
public SortOrder getSortOrder() {
701+
return this.sortOrder;
702+
}
703+
704+
/**
705+
* Setter for property sort.
706+
*
707+
* @param sortOrder New value of property sortOrder.
708+
*/
709+
public void setSortOrder(SortOrder sortOrder) {
710+
this.sortOrder = sortOrder;
711+
}
649712
}

opengrok-indexer/src/test/java/org/opengrok/indexer/search/SearchEngineTest.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
package org.opengrok.indexer.search;
2525

2626
import java.io.File;
27+
import java.net.URL;
28+
import java.nio.file.Path;
29+
import java.util.ArrayList;
2730
import java.util.Collections;
31+
import java.util.List;
2832
import java.util.TreeSet;
2933

3034
import org.junit.jupiter.api.AfterAll;
35+
import org.junit.jupiter.api.Assertions;
3136
import org.junit.jupiter.api.BeforeAll;
3237
import org.junit.jupiter.api.Test;
3338
import org.opengrok.indexer.configuration.RuntimeEnvironment;
@@ -36,7 +41,9 @@
3641
import org.opengrok.indexer.util.TestRepository;
3742

3843
import org.opengrok.indexer.history.RepositoryFactory;
44+
import org.opengrok.indexer.web.SortOrder;
3945

46+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4047
import static org.junit.jupiter.api.Assertions.assertEquals;
4148
import static org.junit.jupiter.api.Assertions.assertFalse;
4249
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -55,7 +62,10 @@ class SearchEngineTest {
5562
@BeforeAll
5663
static void setUpClass() throws Exception {
5764
repository = new TestRepository();
58-
repository.create(HistoryGuru.class.getResource("/repositories"));
65+
URL url = HistoryGuru.class.getResource("/repositories");
66+
repository.createEmpty();
67+
Assertions.assertNotNull(url);
68+
repository.copyDirectoryWithUniqueModifiedTime(Path.of(url.toURI()), Path.of(repository.getSourceRoot()));
5969

6070
RuntimeEnvironment env = RuntimeEnvironment.getInstance();
6171
env.setSourceRoot(repository.getSourceRoot());
@@ -148,6 +158,73 @@ void testGetQuery() throws Exception {
148158
instance.getQuery());
149159
}
150160

161+
@Test
162+
void testSortOrderLastModified() {
163+
SearchEngine instance = new SearchEngine();
164+
instance.setFile("main.c");
165+
instance.setFreetext("arguments");
166+
instance.setSortOrder(SortOrder.LASTMODIFIED);
167+
int hitsCount = instance.search();
168+
List<Hit> hits = new ArrayList<>();
169+
instance.results(0, hitsCount, hits);
170+
assertTrue(hits.size() != 6, "Should return at least 2 hits for RELEVANCY sort to check order");
171+
172+
String[] results = hits.stream().
173+
map(hit -> hit.getPath() + "@" + hit.getLineno()).
174+
toArray(String[]::new);
175+
final String[] expectedResults = {
176+
"/teamware/main.c@5",
177+
"/rcs_test/main.c@5",
178+
"/mercurial/main.c@5",
179+
"/git/main.c@5",
180+
"/cvs_test/cvsrepo/main.c@7",
181+
"/bazaar/main.c@5"
182+
};
183+
184+
assertArrayEquals(expectedResults, results);
185+
186+
instance.destroy();
187+
}
188+
189+
@Test
190+
void testSortOrderByPath() {
191+
SearchEngine instance = new SearchEngine();
192+
instance.setFile("main.c OR header.h");
193+
instance.setFreetext("arguments OR stdio");
194+
instance.setSortOrder(SortOrder.BY_PATH);
195+
int hitsCount = instance.search();
196+
List<Hit> hits = new ArrayList<>();
197+
instance.results(0, hitsCount, hits);
198+
assertTrue(hits.size() != 11, "Should return at least 2 hits for RELEVANCY sort to check order");
199+
200+
String[] results = hits.stream().
201+
map(hit -> hit.getPath() + "@" + hit.getLineno()).
202+
toArray(String[]::new);
203+
final String[] expectedResults = {
204+
"/bazaar/header.h@2",
205+
"/bazaar/main.c@5",
206+
"/cvs_test/cvsrepo/main.c@7",
207+
"/git/header.h@2",
208+
"/git/main.c@5",
209+
"/mercurial/header.h@2",
210+
"/mercurial/main.c@5",
211+
"/rcs_test/header.h@2",
212+
"/rcs_test/main.c@5",
213+
"/teamware/header.h@2",
214+
"/teamware/main.c@5"
215+
};
216+
217+
assertArrayEquals(expectedResults, results);
218+
219+
instance.destroy();
220+
}
221+
222+
@Test
223+
void testDefaultSortOrder() {
224+
SearchEngine instance = new SearchEngine();
225+
assertNull(instance.getSortOrder(), "Default sort should be relevancy (null implies Lucene score ordering)");
226+
}
227+
151228
/* see https://github.com/oracle/opengrok/issues/2030
152229
@Test
153230
void testSearch() {

opengrok-indexer/src/test/java/org/opengrok/indexer/util/TestRepository.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.nio.file.Files;
3434
import java.nio.file.Path;
3535
import java.util.LinkedHashMap;
36+
import java.util.List;
3637
import java.util.Map;
3738
import java.util.stream.Stream;
3839

@@ -133,6 +134,44 @@ public void copyDirectory(Path src, Path dest) throws IOException {
133134
}
134135
}
135136

137+
/**
138+
* Assumes the destination directory exists.
139+
* @param src source directory
140+
* @param dest destination directory
141+
* @throws IOException on error
142+
*/
143+
public void copyDirectoryWithUniqueModifiedTime(Path src, Path dest) throws IOException {
144+
// Create a deterministic order of paths for creation time, so last modified time indexing is stable in tests
145+
// note we cannot use Files.copy(sourceFile, destPath, REPLACE_EXISTING, COPY_ATTRIBUTES)
146+
// as the original creation time is the user checkout and not different accross files
147+
List<Path> allPaths;
148+
try (Stream<Path> stream = Files.walk(src)) {
149+
allPaths = stream.filter(p -> !p.equals(src)).sorted().toList();
150+
}
151+
// Set base time to now, and go ahead in time for each subsequent path by 1 minute
152+
java.time.Instant baseTime = java.time.Instant.now();
153+
for (int i = 0; i < allPaths.size(); i++) {
154+
Path sourcePath = allPaths.get(i);
155+
Path destRelativePath = getDestinationRelativePath(src, sourcePath);
156+
Path destPath = dest.resolve(destRelativePath);
157+
var fileTime = java.nio.file.attribute.FileTime.from(baseTime.plusSeconds(i * 60L));
158+
if (Files.isDirectory(sourcePath)) {
159+
if (!Files.exists(destPath)) {
160+
Files.createDirectories(destPath);
161+
}
162+
Files.setLastModifiedTime(destPath, fileTime);
163+
} else {
164+
// Ensure parent directory exists before copying file
165+
Path parentDir = destPath.getParent();
166+
if (parentDir != null && !Files.exists(parentDir)) {
167+
Files.createDirectories(parentDir);
168+
}
169+
Files.copy(sourcePath, destPath, REPLACE_EXISTING, COPY_ATTRIBUTES);
170+
Files.setLastModifiedTime(destPath, fileTime);
171+
}
172+
}
173+
}
174+
136175
private Path getDestinationRelativePath(Path sourceDirectory, Path sourceFile) {
137176
// possibly strip zip filesystem for the startsWith method to work
138177
var relativePath = Path.of(sourceDirectory.relativize(sourceFile).toString());

opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/SearchController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.opengrok.indexer.search.Hit;
4040
import org.opengrok.indexer.search.SearchEngine;
4141
import org.opengrok.indexer.web.QueryParameters;
42+
import org.opengrok.indexer.web.SortOrder;
4243
import org.opengrok.web.PageConfig;
4344
import org.opengrok.web.api.v1.filter.CorsEnable;
4445
import org.opengrok.web.api.v1.suggester.provider.service.SuggesterService;
@@ -58,6 +59,7 @@ public class SearchController {
5859
public static final String PATH = "search";
5960

6061
private static final int MAX_RESULTS = 1000;
62+
private static final String DEFAULT_SORT_ORDER = "relevancy";
6163

6264
private final SuggesterService suggester;
6365

@@ -81,9 +83,10 @@ public SearchResult search(
8183
@QueryParam("projects") final List<String> projects,
8284
@QueryParam("maxresults") // Akin to QueryParameters.COUNT_PARAM
8385
@DefaultValue(MAX_RESULTS + "") final int maxResults,
84-
@QueryParam(QueryParameters.START_PARAM) @DefaultValue(0 + "") final int startDocIndex
86+
@QueryParam(QueryParameters.START_PARAM) @DefaultValue(0 + "") final int startDocIndex,
87+
@QueryParam(QueryParameters.SORT_PARAM) @DefaultValue(DEFAULT_SORT_ORDER) final String sort
8588
) {
86-
try (SearchEngineWrapper engine = new SearchEngineWrapper(full, def, symbol, path, hist, type)) {
89+
try (SearchEngineWrapper engine = new SearchEngineWrapper(full, def, symbol, path, hist, type, SortOrder.get(sort))) {
8790

8891
if (!engine.isValid()) {
8992
throw new WebApplicationException("Invalid request", Response.Status.BAD_REQUEST);
@@ -119,14 +122,16 @@ private SearchEngineWrapper(
119122
final String symbol,
120123
final String path,
121124
final String hist,
122-
final String type
125+
final String type,
126+
final SortOrder sortOrder
123127
) {
124128
engine.setFreetext(full);
125129
engine.setDefinition(def);
126130
engine.setSymbol(symbol);
127131
engine.setFile(path);
128132
engine.setHistory(hist);
129133
engine.setType(type);
134+
engine.setSortOrder(sortOrder);
130135
}
131136

132137
public List<Hit> search(

opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/IncomingFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public class IncomingFilter implements ContainerRequestFilter, ConfigurationChan
6666
/**
6767
* Endpoint paths that are exempted from this filter.
6868
* @see SearchController#search(HttpServletRequest, String, String, String, String, String, String,
69-
* java.util.List, int, int)
69+
* java.util.List, int, int, String)
7070
* @see SuggesterController#getSuggestions(org.opengrok.web.api.v1.suggester.model.SuggesterQueryData)
7171
* @see SuggesterController#getConfig()
7272
*/

0 commit comments

Comments
 (0)