Support arbitrary account queries via REST

GET /accounts/?q=<query> supports arbitrary account queries now. So
far this REST endpoint was used for account suggestions, suggesting
accounts for a substring that the user has typed. This will continue
to work since this is handled by the default field for account
queries. However if the provided query contains query operators a
full-fledged account query is executed. This is undesired for account
suggestions. This is why the old behaviour for account suggestions is
preserved when the parameter 'suggest' is provided. The web UI and the
java extension API are adapted to set this new parameter for account
suggestions. For old clients the new REST endpoint provides enough
backward compatibility so that they are not breaking as it is rather
unlikely that a substring that was typed by a user contains a query
operator.

Using /accounts/?q=<query> for arbitrary account queries has the
advantage that this is consistent with the REST endpoint for querying
changes which is /changes/?q=<query>.

Rename SuggestAccounts to QueryAccounts to reflect the new
functionality and to have a consistent name with QueryChanges.

Since the account query predicates are now exposed to external users
this change also adds documentation for them.

Change-Id: Iabe3e52893a17a21d4bfdec6dab737e0cebaea23
Signed-off-by: Edwin Kempin <ekempin@google.com>
This commit is contained in:
Edwin Kempin
2016-06-30 11:33:47 +02:00
parent 4c8a1340a1
commit c236140227
6 changed files with 188 additions and 41 deletions

View File

@@ -7,19 +7,56 @@ link:rest-api.html[REST API].
[[account-endpoints]] [[account-endpoints]]
== Account Endpoints == Account Endpoints
[[suggest-account]] [[query-account]]
=== Suggest Account === Query Account
-- --
'GET /accounts/' 'GET /accounts/'
-- --
Suggest users for a given query `q` and result limit `n`. If result Queries accounts visible to the caller. The
limit is not passed, then the default 10 is used. Returns a list of link:user-search-accounts.html#_search_operators[query string] must be
matching link:#account-info[AccountInfo] entities. provided by the `q` parameter. The `n` parameter can be used to limit
the returned results.
As result a list of link:#account-info[AccountInfo] entities is
returned.
.Request .Request
---- ----
GET /accounts/?q=John HTTP/1.0 GET /accounts/?q=name:John+email:example.com HTTP/1.0
----
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
[
{
"_account_id": 1000096,
"name": "John Doe",
"email": "john.doe@example.com",
"username": "john"
},
{
"_account_id": 1001439,
"name": "John Smith",
"email": "john.smith@example.com",
"username": "jsmith"
}
]
----
[[suggest-account]]
To get account suggestions set the parameter `suggest` and provide the
typed substring as query `q`. If a result limit `n` is not specified,
then the default 10 is used.
.Request
----
GET /accounts/?suggest&q=John HTTP/1.0
---- ----
.Response .Response

View File

@@ -0,0 +1,83 @@
= Gerrit Code Review - Searching Accounts
== Basic Change Search
Similar to many popular search engines on the web, just enter some
text and let Gerrit figure out the meaning:
[options="header"]
|=============================================================
|Description | Examples
|Name | John
|Email address | jdoe@example.com
|Username | jdoe
|Account-Id | 1000096
|Own account | self
|=============================================================
[[search-operators]]
== Search Operators
Operators act as restrictions on the search. As more operators
are added to the same query string, they further restrict the
returned results. Search can also be performed by typing only a
text with no operator, which will match against a variety of fields.
[[email]]
email:'EMAIL'::
+
Matches accounts that have the email address 'EMAIL' or an email
address that starts with 'EMAIL'.
[[is]]
[[is-active]]
is:active::
+
Matches accounts that are active.
[[is-inactive]]
is:inactive::
+
Matches accounts that are inactive.
[[name]]
name:'NAME'::
+
Matches accounts that have any name part 'NAME'. The name parts consist
of any part of the full name and the email addresses.
[[username]]
username:'USERNAME'::
+
Matches accounts that have the username 'USERNAME'.
== Magical Operators
[[is-visible]]
is:visible::
+
Magical internal flag to prove the current user has access to read
the change. This flag is always added to any query.
[[is-active-magic]]
is:active::
+
Matches accounts that are active. If neither link:#is-active[is:active]
nor link:#is-inactive[is:inactive] is contained in a query, `is:active`
is automatically added so that by default only active accounts are
matched.
[[limit]]
limit:'CNT'::
+
Limit the returned results to no more than 'CNT' records. This is
automatically set to the page size configured in the current user's
preferences. Including it in a web query may lead to unpredictable
results with regards to pagination.
GERRIT
------
Part of link:index.html[Gerrit Code Review]
SEARCHBOX
---------

View File

@@ -53,6 +53,7 @@ public class AccountApi {
public static void suggest(String query, int limit, public static void suggest(String query, int limit,
AsyncCallback<JsArray<AccountInfo>> cb) { AsyncCallback<JsArray<AccountInfo>> cb) {
new RestApi("/accounts/") new RestApi("/accounts/")
.addParameterTrue("suggest")
.addParameter("q", query) .addParameter("q", query)
.addParameter("n", limit) .addParameter("n", limit)
.background() .background()

View File

@@ -40,7 +40,7 @@ public class AccountsCollection implements
private final AccountResolver resolver; private final AccountResolver resolver;
private final AccountControl.Factory accountControlFactory; private final AccountControl.Factory accountControlFactory;
private final IdentifiedUser.GenericFactory userFactory; private final IdentifiedUser.GenericFactory userFactory;
private final Provider<SuggestAccounts> list; private final Provider<QueryAccounts> list;
private final DynamicMap<RestView<AccountResource>> views; private final DynamicMap<RestView<AccountResource>> views;
private final CreateAccount.Factory createAccountFactory; private final CreateAccount.Factory createAccountFactory;
@@ -49,7 +49,7 @@ public class AccountsCollection implements
AccountResolver resolver, AccountResolver resolver,
AccountControl.Factory accountControlFactory, AccountControl.Factory accountControlFactory,
IdentifiedUser.GenericFactory userFactory, IdentifiedUser.GenericFactory userFactory,
Provider<SuggestAccounts> list, Provider<QueryAccounts> list,
DynamicMap<RestView<AccountResource>> views, DynamicMap<RestView<AccountResource>> views,
CreateAccount.Factory createAccountFactory) { CreateAccount.Factory createAccountFactory) {
this.self = self; this.self = self;

View File

@@ -26,7 +26,9 @@ import com.google.gerrit.reviewdb.client.AccountExternalId;
import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.api.accounts.AccountInfoComparator; import com.google.gerrit.server.api.accounts.AccountInfoComparator;
import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.index.account.AccountIndex;
import com.google.gerrit.server.index.account.AccountIndexCollection; import com.google.gerrit.server.index.account.AccountIndexCollection;
import com.google.gerrit.server.query.Predicate;
import com.google.gerrit.server.query.QueryParseException; import com.google.gerrit.server.query.QueryParseException;
import com.google.gerrit.server.query.QueryResult; import com.google.gerrit.server.query.QueryResult;
import com.google.gerrit.server.query.account.AccountQueryBuilder; import com.google.gerrit.server.query.account.AccountQueryBuilder;
@@ -44,8 +46,8 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public class SuggestAccounts implements RestReadView<TopLevelResource> { public class QueryAccounts implements RestReadView<TopLevelResource> {
private static final int MAX_RESULTS = 100; private static final int MAX_SUGGEST_RESULTS = 100;
private static final String MAX_SUFFIX = "\u9fa5"; private static final String MAX_SUFFIX = "\u9fa5";
private final AccountControl accountControl; private final AccountControl accountControl;
@@ -55,22 +57,29 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
private final AccountQueryBuilder queryBuilder; private final AccountQueryBuilder queryBuilder;
private final AccountQueryProcessor queryProcessor; private final AccountQueryProcessor queryProcessor;
private final ReviewDb db; private final ReviewDb db;
private final boolean suggest; private final boolean suggestConfig;
private final int suggestFrom; private final int suggestFrom;
private int limit = 10; private boolean suggest;
private int suggestLimit = 10;
private String query; private String query;
@Option(name = "--suggest", metaVar = "SUGGEST", usage = "suggest users")
public void setSuggest(boolean suggest) {
this.suggest = suggest;
}
@Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return") @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return")
public void setLimit(int n) { public void setLimit(int n) {
queryProcessor.setLimit(n);
if (n < 0) { if (n < 0) {
limit = 10; suggestLimit = 10;
} else if (n == 0) { } else if (n == 0) {
limit = MAX_RESULTS; suggestLimit = MAX_SUGGEST_RESULTS;
} else { } else {
limit = Math.min(n, MAX_RESULTS); suggestLimit = Math.min(n, MAX_SUGGEST_RESULTS);
} }
queryProcessor.setLimit(limit);
} }
@Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users") @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users")
@@ -79,7 +88,7 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
} }
@Inject @Inject
SuggestAccounts(AccountControl.Factory accountControlFactory, QueryAccounts(AccountControl.Factory accountControlFactory,
AccountLoader.Factory accountLoaderFactory, AccountLoader.Factory accountLoaderFactory,
AccountCache accountCache, AccountCache accountCache,
AccountIndexCollection indexes, AccountIndexCollection indexes,
@@ -97,7 +106,7 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
this.suggestFrom = cfg.getInt("suggest", null, "from", 0); this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) { if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
suggest = false; suggestConfig = false;
} else { } else {
boolean suggest; boolean suggest;
try { try {
@@ -107,7 +116,7 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
} catch (IllegalArgumentException err) { } catch (IllegalArgumentException err) {
suggest = cfg.getBoolean("suggest", null, "accounts", true); suggest = cfg.getBoolean("suggest", null, "accounts", true);
} }
this.suggest = suggest; this.suggestConfig = suggest;
} }
} }
@@ -118,33 +127,49 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
throw new BadRequestException("missing query field"); throw new BadRequestException("missing query field");
} }
if (!suggest || query.length() < suggestFrom) { if (suggest && (!suggestConfig || query.length() < suggestFrom)) {
return Collections.emptyList(); return Collections.emptyList();
} }
Collection<AccountInfo> matches = AccountIndex searchIndex = indexes.getSearchIndex();
indexes.getSearchIndex() != null Collection<AccountInfo> matches;
? queryFromIndex() if (searchIndex != null) {
: queryFromDb(); matches = queryFromIndex();
} else {
if (!suggest) {
throw new MethodNotAllowedException();
}
matches = queryFromDb();
}
return AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches); return AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches);
} }
public Collection<AccountInfo> queryFromIndex() public Collection<AccountInfo> queryFromIndex()
throws MethodNotAllowedException, OrmException { throws BadRequestException, MethodNotAllowedException, OrmException {
if (queryProcessor.isDisabled()) { if (queryProcessor.isDisabled()) {
throw new MethodNotAllowedException("query disabled"); throw new MethodNotAllowedException("query disabled");
} }
Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>(); Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
try { try {
QueryResult<AccountState> result = Predicate<AccountState> queryPred;
queryProcessor.query(queryBuilder.defaultField(query)); if (suggest) {
queryPred = queryBuilder.defaultField(query);
queryProcessor.setLimit(suggestLimit);
} else {
queryPred = queryBuilder.parse(query);
}
QueryResult<AccountState> result = queryProcessor.query(queryPred);
for (AccountState accountState : result.entities()) { for (AccountState accountState : result.entities()) {
Account.Id id = accountState.getAccount().getId(); Account.Id id = accountState.getAccount().getId();
matches.put(id, accountLoader.get(id)); matches.put(id, accountLoader.get(id));
} }
} catch (QueryParseException e) { } catch (QueryParseException e) {
return ImmutableSet.of(); if (suggest) {
return ImmutableSet.of();
}
throw new BadRequestException(e.getMessage());
} }
accountLoader.fill(); accountLoader.fill();
@@ -158,18 +183,18 @@ public class SuggestAccounts implements RestReadView<TopLevelResource> {
Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>(); Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
Map<Account.Id, String> queryEmail = new HashMap<>(); Map<Account.Id, String> queryEmail = new HashMap<>();
for (Account p : db.accounts().suggestByFullName(a, b, limit)) { for (Account p : db.accounts().suggestByFullName(a, b, suggestLimit)) {
addSuggestion(matches, p); addSuggestion(matches, p);
} }
if (matches.size() < limit) { if (matches.size() < suggestLimit) {
for (Account p : db.accounts() for (Account p : db.accounts()
.suggestByPreferredEmail(a, b, limit - matches.size())) { .suggestByPreferredEmail(a, b, suggestLimit - matches.size())) {
addSuggestion(matches, p); addSuggestion(matches, p);
} }
} }
if (matches.size() < limit) { if (matches.size() < suggestLimit) {
for (AccountExternalId e : db.accountExternalIds() for (AccountExternalId e : db.accountExternalIds()
.suggestByEmailAddress(a, b, limit - matches.size())) { .suggestByEmailAddress(a, b, suggestLimit - matches.size())) {
if (addSuggestion(matches, e.getAccountId())) { if (addSuggestion(matches, e.getAccountId())) {
queryEmail.put(e.getAccountId(), e.getEmailAddress()); queryEmail.put(e.getAccountId(), e.getEmailAddress());
} }

View File

@@ -24,7 +24,7 @@ import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountResource; import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.AccountsCollection; import com.google.gerrit.server.account.AccountsCollection;
import com.google.gerrit.server.account.SuggestAccounts; import com.google.gerrit.server.account.QueryAccounts;
import com.google.gwtorm.server.OrmException; import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Provider; import com.google.inject.Provider;
@@ -37,17 +37,17 @@ public class AccountsImpl implements Accounts {
private final AccountsCollection accounts; private final AccountsCollection accounts;
private final AccountApiImpl.Factory api; private final AccountApiImpl.Factory api;
private final Provider<CurrentUser> self; private final Provider<CurrentUser> self;
private final Provider<SuggestAccounts> suggestAccountsProvider; private final Provider<QueryAccounts> queryAccountsProvider;
@Inject @Inject
AccountsImpl(AccountsCollection accounts, AccountsImpl(AccountsCollection accounts,
AccountApiImpl.Factory api, AccountApiImpl.Factory api,
Provider<CurrentUser> self, Provider<CurrentUser> self,
Provider<SuggestAccounts> suggestAccountsProvider) { Provider<QueryAccounts> queryAccountsProvider) {
this.accounts = accounts; this.accounts = accounts;
this.api = api; this.api = api;
this.self = self; this.self = self;
this.suggestAccountsProvider = suggestAccountsProvider; this.queryAccountsProvider = queryAccountsProvider;
} }
@Override @Override
@@ -92,10 +92,11 @@ public class AccountsImpl implements Accounts {
private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r) private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r)
throws RestApiException { throws RestApiException {
try { try {
SuggestAccounts mySuggestAccounts = suggestAccountsProvider.get(); QueryAccounts myQueryAccounts = queryAccountsProvider.get();
mySuggestAccounts.setQuery(r.getQuery()); myQueryAccounts.setSuggest(true);
mySuggestAccounts.setLimit(r.getLimit()); myQueryAccounts.setQuery(r.getQuery());
return mySuggestAccounts.apply(TopLevelResource.INSTANCE); myQueryAccounts.setLimit(r.getLimit());
return myQueryAccounts.apply(TopLevelResource.INSTANCE);
} catch (OrmException e) { } catch (OrmException e) {
throw new RestApiException("Cannot retrieve suggested accounts", e); throw new RestApiException("Cannot retrieve suggested accounts", e);
} }