Support searching changes which only touch certain file extensions

With the current 'extension' search operator it's possible to find
a) changes that contain at least one file with the given extension,
   e.g.: extension:txt
b) changes that contain no file with the given extension,
   e.g.: -extension:txt

However sometimes you want to match changes which only contain files of
a given extension, e.g. changes that only touch txt files. This can now
be done with the new 'only_extensions' search operator, e.g.:
  only_extensions:txt

It is also possible to specify multiple file extensions. E.g. matching
changes that only touch txt and jpg files can be done by:
  only_extensions:jpg,txt

By reversing the 'only_extensions' search operator it is possible to
match changes that not only touch files with certain extensions, e.g.:
  -only_extensions:jpg,txt

The order and the case in which the extensions are provided to the
'only_extensions' operator don't matter.

Also extensions can be specified with or without leading '.' (same as
for the 'extension' search operator).

Changes that contain files without file extension can be matched by
including an empty file extension into the file extension list, e.g.:
* changes that only include txt files and files without extension:
  only_extensions:,txt
* changes that only contain files without extension:
  only_extensions:,
  only_extensions:""

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I3d2b978ed455f835d1dad2daa920be0b0ec2ae36
This commit is contained in:
Edwin Kempin
2019-01-25 10:34:07 +01:00
parent b746dadf3e
commit 2b2f3897cf
8 changed files with 167 additions and 7 deletions

View File

@@ -297,6 +297,15 @@ extension is defined as the portion of the filename following the final `.`.
Files with no `.` in their name have no extension and cannot be matched with
this operator; use `file:` instead.
[[onlyextensions]]
onlyextensions:'EXT_LIST', onlyexts:'EXT_LIST'::
+
Matches any change touching only files with extensions that are listed in
'EXT_LIST' (comma-separated list). The matching is done case-insensitive.
An extension is defined as the portion of the filename following the final `.`.
Files with no `.` in their name have no extension and can be matched by an
empty string.
[[star]]
star:'LABEL'::
+

View File

@@ -24,6 +24,7 @@ import static com.google.gerrit.index.FieldDef.prefix;
import static com.google.gerrit.index.FieldDef.storedOnly;
import static com.google.gerrit.index.FieldDef.timestamp;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
@@ -191,6 +192,29 @@ public class ChangeField {
exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
public static Set<String> getExtensions(ChangeData cd) throws OrmException {
return extensions(cd).filter(e -> !e.isEmpty()).collect(toSet());
}
/**
* File extensions of each file modified in the current patch set as a sorted list. The purpose of
* this field is to allow matching changes that only touch files with certain file extensions.
*/
public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
public static String getAllExtensionsAsList(ChangeData cd) throws OrmException {
return extensions(cd).distinct().sorted().collect(joining(","));
}
/**
* Returns a stream with all file extensions that are used by files in the given change. A file
* extension is defined as the portion of the filename following the final `.`. Files with no `.`
* in their name have no extension. For them an empty string is returned as part of the stream.
*
* <p>If the change contains multiple files with the same extension the extension is returned
* multiple times in the stream (once per file).
*/
private static Stream<String> extensions(ChangeData cd) throws OrmException {
try {
return cd.currentFilePaths()
.stream()
@@ -198,9 +222,7 @@ public class ChangeField {
// If we want to find "all Java files", we want to match both .java and .JAVA, even if we
// normally care about case sensitivity. (Whether we should change the existing file/path
// predicates to be case insensitive is a separate question.)
.map(f -> Files.getFileExtension(f).toLowerCase(Locale.US))
.filter(e -> !e.isEmpty())
.collect(toSet());
.map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
} catch (IOException e) {
throw new OrmException(e);
}

View File

@@ -103,7 +103,9 @@ public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
@Deprecated static final Schema<ChangeData> V51 = schema(V50, ChangeField.TOTAL_COMMENT_COUNT);
static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
@Deprecated static final Schema<ChangeData> V52 = schema(V51, ChangeField.EXTENSION);
static final Schema<ChangeData> V53 = schema(V52, ChangeField.ONLY_EXTENSIONS);
public static final String NAME = "changes";
public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();

View File

@@ -139,6 +139,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
public static final String FIELD_COMMITTER = "committer";
public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
public static final String FIELD_EXTENSION = "extension";
public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
public static final String FIELD_CONFLICTS = "conflicts";
public static final String FIELD_DELETED = "deleted";
public static final String FIELD_DELTA = "delta";
@@ -747,6 +748,20 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
throw new QueryParseException("'extension' operator is not supported by change index version");
}
@Operator
public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
return onlyextensions(extList);
}
@Operator
public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
return new FileExtensionListPredicate(extList);
}
throw new QueryParseException(
"'onlyextensions' operator is not supported by change index version");
}
@Operator
public Predicate<ChangeData> label(String name)
throws QueryParseException, OrmException, IOException, ConfigInvalidException {

View File

@@ -0,0 +1,47 @@
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.query.change;
import static java.util.stream.Collectors.joining;
import com.google.common.base.Splitter;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gwtorm.server.OrmException;
public class FileExtensionListPredicate extends ChangeIndexPredicate {
private static String clean(String extList) {
return Splitter.on(',')
.splitToList(extList)
.stream()
.map(FileExtensionPredicate::clean)
.distinct()
.sorted()
.collect(joining(","));
}
FileExtensionListPredicate(String value) {
super(ChangeField.ONLY_EXTENSIONS, clean(value));
}
@Override
public boolean match(ChangeData cd) throws OrmException {
return ChangeField.getAllExtensionsAsList(cd).equals(value);
}
@Override
public int getCost() {
return 0;
}
}

View File

@@ -19,7 +19,7 @@ import com.google.gwtorm.server.OrmException;
import java.util.Locale;
public class FileExtensionPredicate extends ChangeIndexPredicate {
private static String clean(String ext) {
static String clean(String ext) {
if (ext.startsWith(".")) {
ext = ext.substring(1);
}

View File

@@ -1389,6 +1389,62 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("ext:cc", change3, change2, change1);
}
@Test
public void byOnlyExtensions() throws Exception {
if (getSchemaVersion() < 53) {
assertMissingField(ChangeField.ONLY_EXTENSIONS);
String unsupportedOperatorMessage =
"'onlyextensions' operator is not supported by change index version";
assertFailingQuery("onlyextensions:txt,jpg", unsupportedOperatorMessage);
assertFailingQuery("onlyexts:txt,jpg", unsupportedOperatorMessage);
return;
}
TestRepository<Repo> repo = createProject("repo");
Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
// case doesn't matter
assertQuery("onlyextensions:cc,h", change4, change2, change1);
assertQuery("onlyextensions:CC,H", change4, change2, change1);
assertQuery("onlyextensions:cc,H", change4, change2, change1);
assertQuery("onlyextensions:cC,h", change4, change2, change1);
assertQuery("onlyextensions:cc", change3);
assertQuery("onlyextensions:CC", change3);
assertQuery("onlyexts:java", change5);
assertQuery("onlyexts:jAvA", change5);
assertQuery("onlyexts:.jAvA", change5);
// order doesn't matter
assertQuery("onlyextensions:h,cc", change4, change2, change1);
assertQuery("onlyextensions:H,CC", change4, change2, change1);
// specifying extension with '.' is okay
assertQuery("onlyextensions:.cc,.h", change4, change2, change1);
assertQuery("onlyextensions:cc,.h", change4, change2, change1);
assertQuery("onlyextensions:.cc,h", change4, change2, change1);
assertQuery("onlyexts:.java", change5);
// matching changes without extension is possible
assertQuery("onlyexts:txt");
assertQuery("onlyexts:txt,", change6);
assertQuery("onlyexts:,txt", change6);
assertQuery("onlyextensions:\"\"", change7);
assertQuery("onlyexts:\"\"", change7);
assertQuery("onlyextensions:,", change7);
assertQuery("onlyexts:,", change7);
assertFailingQuery("onlyextensions:");
assertFailingQuery("onlyexts:");
// inverse queries
assertQuery("-onlyextensions:cc,h", change7, change6, change5, change3);
}
@Test
public void byComment() throws Exception {
TestRepository<Repo> repo = createProject("repo");
@@ -3126,12 +3182,19 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.isFalse();
}
protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
protected void assertFailingQuery(String query) throws Exception {
assertFailingQuery(query, null);
}
protected void assertFailingQuery(String query, @Nullable String expectedMessage)
throws Exception {
try {
assertQuery(query);
fail("expected BadRequestException for query '" + query + "'");
} catch (BadRequestException e) {
assertThat(e.getMessage()).isEqualTo(expectedMessage);
if (expectedMessage != null) {
assertThat(e.getMessage()).isEqualTo(expectedMessage);
}
}
}

View File

@@ -66,6 +66,8 @@
'is:wip',
'label:',
'message:',
'onlyexts:',
'onlyextensions:',
'owner:',
'ownerin:',
'parentproject:',