View Javadoc
1   /*
2    * Copyright (C) 2003-2015 eXo Platform SAS.
3    *
4    * This program is free software: you can redistribute it and/or modify
5    * it under the terms of the GNU Affero General Public License as published by
6    * the Free Software Foundation, either version 3 of the License, or
7    * (at your option) any later version.
8    *
9    * This program is distributed in the hope that it will be useful,
10   * but WITHOUT ANY WARRANTY; without even the implied warranty of
11   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12   * GNU Affero General Public License for more details.
13   *
14   * You should have received a copy of the GNU Affero General Public License
15   * along with this program. If not, see <http://www.gnu.org/licenses/>.
16   */
17  package org.exoplatform.social.core.jpa.search;
18  
19  import java.text.Normalizer;
20  import java.util.ArrayList;
21  import java.util.Iterator;
22  import java.util.List;
23  import java.util.Map;
24  
25  import org.exoplatform.commons.search.es.ElasticSearchException;
26  import org.exoplatform.commons.search.es.client.ElasticSearchingClient;
27  import org.json.simple.JSONArray;
28  import org.json.simple.JSONObject;
29  import org.json.simple.parser.JSONParser;
30  import org.json.simple.parser.ParseException;
31  import org.apache.commons.lang.StringUtils;
32  
33  import org.exoplatform.container.xml.InitParams;
34  import org.exoplatform.container.xml.PropertiesParam;
35  import org.exoplatform.services.log.ExoLogger;
36  import org.exoplatform.services.log.Log;
37  import org.exoplatform.social.core.identity.model.Identity;
38  import org.exoplatform.social.core.identity.model.Profile;
39  import org.exoplatform.social.core.identity.provider.OrganizationIdentityProvider;
40  import org.exoplatform.social.core.profile.ProfileFilter;
41  import org.exoplatform.social.core.relationship.model.Relationship.Type;
42  import org.exoplatform.social.core.search.Sorting;
43  import org.exoplatform.social.core.search.Sorting.SortBy;
44  import org.exoplatform.social.core.service.LinkProvider;
45  import org.exoplatform.social.core.storage.impl.StorageUtils;
46  
47  /**
48   * Created by The eXo Platform SAS
49   * Author : eXoPlatform
50   *          exo@exoplatform.com
51   * Sep 29, 2015  
52   */
53  public class ProfileSearchConnector {
54    private static final Log LOG = ExoLogger.getLogger(ProfileSearchConnector.class);
55    private final ElasticSearchingClient client;
56    private String index;
57    private String searchType;
58    
59    public ProfileSearchConnector(InitParams initParams, ElasticSearchingClient client) {
60      PropertiesParam param = initParams.getPropertiesParam("constructor.params");
61      this.index = param.getProperty("index");
62      this.searchType = param.getProperty("searchType");
63      this.client = client;
64    }
65  
66    public List<Identity> search(Identity identity,
67                                       ProfileFilter filter,
68                                       Type type,
69                                       long offset,
70                                       long limit) {
71      if(identity == null && filter.getViewerIdentity() != null) {
72        identity = filter.getViewerIdentity();
73      }
74      String esQuery = buildQueryStatement(identity, filter, type, offset, limit);
75      String jsonResponse = this.client.sendRequest(esQuery, this.index, this.searchType);
76      return buildResult(jsonResponse);
77    }
78  
79    /**
80     * TODO it will be remove to use "_count" query
81     * 
82     * @param identity the Identity
83     * @param filter the filter
84     * @param type type type
85     * @return number of identities
86     */
87    public int count(Identity identity,
88                                 ProfileFilter filter,
89                                 Type type) {
90      String esQuery = buildQueryStatement(identity, filter, type, 0, 1);
91      String jsonResponse = this.client.sendRequest(esQuery, this.index, this.searchType);
92      return getCount(jsonResponse);
93    }
94  
95    private int getCount(String jsonResponse) {
96      
97      LOG.debug("Search Query response from ES : {} ", jsonResponse);
98      JSONParser parser = new JSONParser();
99  
100     Map<?, ?> json = null;
101     try {
102       json = (Map<?, ?>)parser.parse(jsonResponse);
103     } catch (ParseException e) {
104       throw new ElasticSearchException("Unable to parse JSON response", e);
105     }
106 
107     JSONObject jsonResult = (JSONObject) json.get("hits");
108     if (jsonResult == null) return 0;
109 
110     int count = Integer.parseInt(jsonResult.get("total").toString());
111     return count;
112   }
113   
114   private List<Identity> buildResult(String jsonResponse) {
115 
116     LOG.debug("Search Query response from ES : {} ", jsonResponse);
117 
118     List<Identity> results = new ArrayList<Identity>();
119     JSONParser parser = new JSONParser();
120 
121     Map json = null;
122     try {
123       json = (Map)parser.parse(jsonResponse);
124     } catch (ParseException e) {
125       throw new ElasticSearchException("Unable to parse JSON response", e);
126     }
127 
128     JSONObject jsonResult = (JSONObject) json.get("hits");
129     if (jsonResult == null) return results;
130 
131     //
132     JSONArray jsonHits = (JSONArray) jsonResult.get("hits");
133     Identity identity = null;
134     Profile p;
135     for(Object jsonHit : jsonHits) {
136       JSONObject hitSource = (JSONObject) ((JSONObject) jsonHit).get("_source");
137       String position = (String) hitSource.get("position");
138       String name = (String) hitSource.get("name");
139       String userName = (String) hitSource.get("userName");
140       String firstName = (String) hitSource.get("firstName");
141       String lastName = (String) hitSource.get("lastName");
142       String avatarUrl = (String) hitSource.get("avatarUrl");
143       String email = (String) hitSource.get("email");
144       String identityId = (String) ((JSONObject) jsonHit).get("_id");
145       identity = new Identity(OrganizationIdentityProvider.NAME, userName);
146       identity.setId(identityId);
147       p = new Profile(identity);
148       p.setId(identityId);
149       p.setAvatarUrl(avatarUrl);
150       p.setUrl(LinkProvider.getProfileUri(userName));
151       p.setProperty(Profile.FULL_NAME, name);
152       p.setProperty(Profile.FIRST_NAME, firstName);
153       p.setProperty(Profile.LAST_NAME, lastName);
154       p.setProperty(Profile.POSITION, position);
155       p.setProperty(Profile.EMAIL, email);
156       p.setProperty(Profile.USERNAME, userName);
157       identity.setProfile(p);
158       results.add(identity);
159     }
160     return results;
161   }
162 
163   private String buildQueryStatement(Identity identity, ProfileFilter filter, Type type, long offset, long limit) {
164     String expEs = buildExpression(filter);
165     StringBuilder esQuery = new StringBuilder();
166     esQuery.append("{\n");
167     esQuery.append("   \"from\" : " + offset + ", \"size\" : " + limit + ",\n");
168     Sorting sorting = filter.getSorting();
169     if (sorting != null && SortBy.DATE.equals(sorting.sortBy)) {
170       esQuery.append("   \"sort\": {\"lastUpdatedDate\": {\"order\": \""
171           + (sorting.orderBy == null ? "desc" : sorting.orderBy.name()) + "\"}}\n");
172     } else {
173       esQuery.append("   \"sort\": {\"name.raw\": {\"order\": \"asc\"}}\n");
174     }
175     StringBuilder esSubQuery = new StringBuilder();
176     esSubQuery.append("       ,\n");
177     esSubQuery.append("\"query\" : {\n");
178     esSubQuery.append("      \"constant_score\" : {\n");
179     esSubQuery.append("        \"filter\" : {\n");
180     esSubQuery.append("          \"bool\" :{\n");
181     boolean subQueryEmpty = true;
182     if (filter.getRemoteIds() != null && !filter.getRemoteIds().isEmpty()) {
183       subQueryEmpty = false;
184       StringBuilder remoteIds = new StringBuilder();
185       for (String remoteId : filter.getRemoteIds()) {
186         if (remoteIds.length() > 0) {
187           remoteIds.append(",");
188         }
189         remoteIds.append("\"").append(remoteId).append("\"");
190       }
191       esSubQuery.append("      \"must\" : {\n");
192       esSubQuery.append("        \"terms\" :{\n");
193       esSubQuery.append("          \"userName\" : [" + remoteIds.toString() + "]\n");
194       esSubQuery.append("        } \n");
195       esSubQuery.append("      },\n");
196     }
197     if (identity != null && type != null) {
198       subQueryEmpty = false;
199       esSubQuery.append("      \"must\" : {\n");
200       esSubQuery.append("        \"query_string\" : {\n");
201       esSubQuery.append("          \"query\" : \""+ identity.getId() +"\",\n");
202       esSubQuery.append("          \"fields\" : [\"" + buildTypeEx(type) + "\"]\n");
203       esSubQuery.append("        }\n");
204       esSubQuery.append("      }\n");
205     } else if (filter.getExcludedIdentityList() != null && filter.getExcludedIdentityList().size() > 0) {
206       subQueryEmpty = false;
207       esSubQuery.append("      \"must_not\": [\n");
208       esSubQuery.append("        {\n");
209       esSubQuery.append("          \"ids\" : {\n");
210       esSubQuery.append("             \"values\" : [" + buildExcludedIdentities(filter) + "]\n");
211       esSubQuery.append("          }\n");
212       esSubQuery.append("        }\n");
213       esSubQuery.append("      ]\n");
214     }
215     //if the search fields are existing.
216     if (expEs != null && expEs.length() > 0) {
217       if(!subQueryEmpty) {
218         esSubQuery.append("      ,\n");
219       }
220       subQueryEmpty = false;
221       esSubQuery.append("    \"filter\": [\n");
222       esSubQuery.append("      {");
223       esSubQuery.append("          \"query_string\": {\n");
224       esSubQuery.append("            \"query\": \"" + expEs + "\"\n");
225       esSubQuery.append("          }\n");
226       esSubQuery.append("      }\n");
227       esSubQuery.append("    ]\n");
228     } //end if
229     esSubQuery.append("     } \n");
230     esSubQuery.append("   } \n");
231     esSubQuery.append("  }\n");
232     esSubQuery.append(" }\n");
233     if(!subQueryEmpty) {
234       esQuery.append(esSubQuery);
235     }
236 
237     esQuery.append("}\n");
238     LOG.debug("Search Query request to ES : {} ", esQuery);
239 
240     return esQuery.toString();
241   }
242   
243   /**
244    * 
245    * @param filter
246    * @return
247    */
248   private String buildExcludedIdentities(ProfileFilter filter) {
249     StringBuilder typeExp = new StringBuilder();
250     if (filter.getExcludedIdentityList() != null && filter.getExcludedIdentityList().size() > 0) {
251       
252       Iterator<Identity> iter = filter.getExcludedIdentityList().iterator();
253       Identity first = iter.next();
254       typeExp.append("\"").append(first.getId()).append("\"");
255       
256       if (!iter.hasNext()) {
257         return typeExp.toString();
258       }
259       Identity next;
260       while (iter.hasNext()) {
261         next = iter.next();
262         typeExp.append(",\"").append(next.getId()).append("\"");
263       }
264     }
265     return typeExp.toString();
266   }
267   
268   /**
269    * 
270    * @param type
271    * @return
272    */
273   private String buildTypeEx(Type type) {
274     String result;
275     switch(type) {
276       case CONFIRMED:
277         result = "connections";
278         break;
279       case INCOMING:
280         // Search for identity of current user viewer
281         // in the outgoings relationships field of other identities
282         result = "outgoings";
283         break;
284       case OUTGOING:
285         // Search for identity of current user viewer
286         // in the incomings relationships field of other identities
287         result = "incomings";
288         break;
289       default:
290         throw new IllegalArgumentException("Type ["+type+"] not supported");
291     }
292     return result;
293   }
294 
295   private String buildExpression(ProfileFilter filter) {
296     StringBuilder esExp = new StringBuilder();
297     char firstChar = filter.getFirstCharacterOfName();
298     //
299     if (firstChar != '\u0000') {
300       char lowerCase = Character.toLowerCase(firstChar);
301       char upperCase = Character.toUpperCase(firstChar);;
302       esExp.append("lastName.whitespace:").append("(").append(upperCase).append(StorageUtils.ASTERISK_STR).append(" OR ").append(lowerCase).append(StorageUtils.ASTERISK_STR).append(")");
303       return esExp.toString();
304     }
305 
306     //
307     String inputName = StringUtils.isBlank(filter.getName()) ? null : filter.getName().replace(StorageUtils.ASTERISK_STR, StorageUtils.EMPTY_STR);
308     if (StringUtils.isNotBlank(inputName)) {
309      //
310       String[] keys = inputName.split(" ");
311       if (keys.length > 1) {
312         // We will not search on username because it doesn't contain a space character
313         if (filter.isSearchEmail()) {
314           esExp.append("(");
315         }
316         esExp.append("(");
317         for (int i = 0; i < keys.length; i++) {
318           if (i != 0 ) {
319             esExp.append(" AND ") ;
320           }
321           esExp.append(" name.whitespace:").append(StorageUtils.ASTERISK_STR).append(removeAccents(keys[i])).append(StorageUtils.ASTERISK_STR);
322         }
323         esExp.append(")");
324         if (filter.isSearchEmail()) {
325           esExp.append(" OR ( ");
326           for (int i = 0; i < keys.length; i++) {
327             if (i != 0 ) {
328               esExp.append(" AND ") ;
329             }
330             esExp.append(" email:").append(StorageUtils.ASTERISK_STR).append(removeAccents(keys[i])).append(StorageUtils.ASTERISK_STR);
331           }
332           esExp.append(") )");
333         }
334       } else {
335         esExp.append("( name.whitespace:").append(StorageUtils.ASTERISK_STR).append(removeAccents(inputName)).append(StorageUtils.ASTERISK_STR);
336         if (filter.isSearchEmail()) {
337           esExp.append(" OR email:").append(StorageUtils.ASTERISK_STR).append(removeAccents(inputName)).append(StorageUtils.ASTERISK_STR);
338         }
339         esExp.append(" OR userName:").append(StorageUtils.ASTERISK_STR).append(removeAccents(inputName)).append(StorageUtils.ASTERISK_STR).append(")");
340       }
341     }
342 
343     //skills
344     String skills = StringUtils.isBlank(filter.getSkills()) ? null : filter.getSkills().replace(StorageUtils.ASTERISK_STR, StorageUtils.EMPTY_STR);
345     if (StringUtils.isNotBlank(skills)) {
346       if (esExp.length() > 0) {
347         esExp.append(" AND ");
348       }
349       //
350       esExp.append("skills:").append(StorageUtils.ASTERISK_STR).append(removeAccents(skills)).append(StorageUtils.ASTERISK_STR);
351     }
352 
353     //position
354     String position = StringUtils.isBlank(filter.getPosition()) ? null : filter.getPosition().replace(StorageUtils.ASTERISK_STR, StorageUtils.EMPTY_STR);
355     if (StringUtils.isNotBlank(position)) {
356       if (esExp.length() > 0) {
357         esExp.append(" AND ");
358       }
359       esExp.append("position:").append(StorageUtils.ASTERISK_STR).append(removeAccents(position)).append(StorageUtils.ASTERISK_STR);
360     }
361     return esExp.toString();
362   }
363 
364   private static String removeAccents(String string) {
365     string = Normalizer.normalize(string, Normalizer.Form.NFD);
366     string = string.replaceAll("[\\p{InCombiningDiacriticalMarks}]", "");
367     return string;
368   }
369 }