SolrQuery.js

import md5 from 'blueimp-md5';
import { namespaces } from '@entryscape/rdfjson';
import SearchList from './SearchList.js';

const encodeStr = str => encodeURIComponent(str.replace(/:/g, '\\:')
  .replace(/\(/g, '\\(').replace(/\)/g, '\\)'));
const shorten = predicate => md5(namespaces.expand(predicate)).substr(0, 8);
const ngramMaxLimit = 15;
const ngramMinLimit = 3;
const isNgram = key => key.indexOf('title') === 0
  || key.indexOf('description') === 0
  || key.indexOf('tag.literal') === 0
  || (key.indexOf('metadata.predicate.literal') === 0 &&
    key.indexOf('metadata.predicate.literal_') !== 0)
  || (key.indexOf('related.metadata.predicate.literal') === 0 &&
    key.indexOf('related.metadata.predicate.literal_') !== 0);
const isExactMatch = key => key.indexOf('predicate.literal_s') > 0 || key.indexOf('predicate.literal') === -1;

const isDateKey = key => key === 'created' || key === 'modified' || key.indexOf('metadata.predicate.date') >= 0;
const isIntegerKey = key => key.indexOf('metadata.predicate.integer') >= 0;
const isRange = value => Array.isArray(value) ?
  value.find(v => (v.match(/\[.+\sTO\s.+]/) !== null)) != null :
  value.match(/\[.+\sTO\s.+]/) !== null;

const isText = key => key.indexOf('metadata.object.literal') >= 0
  || key.indexOf('metadata.predicate.literal_t') >= 0;

const spaceTokenizerRegExp = /(?:\\\s|[^\s])+/g;

/**
 * Empty spaces in search term should be interpreted as AND instead of the default OR.
 * In addition, fields indexed as text_ngram will have to be shortened to the ngram max limit
 * as they will not match otherwise.
 *
 * @param key
 * @param term
 * @param isFacet
 * @return {*}
 */
const solrFriendly = (key, term, isFacet) => {
  let and = term.trim().replace(/\s\s+/g, ' ');
  let boost = '';
  if (and.indexOf('^') >= 0) {
    const andArr = and.split('^');
    and = andArr[0];
    boost = `^${andArr[1]}`;
  }
  if (isText(key) && isFacet !== true) {
    and = and.match(spaceTokenizerRegExp).map(encodeStr);
  } else if (isNgram(key) && isFacet !== true) {
    and = and.match(spaceTokenizerRegExp).map(t => (t.length < ngramMaxLimit ? encodeStr(t)
      : encodeStr(t.substr(0, ngramMaxLimit))))
      .map(t => (t.length < ngramMinLimit && !t.endsWith('*') ? `${t}*` : t));
  } else if (isDateKey(key) || isIntegerKey(key) || isRange(and)) {
    and = Array.isArray(and) ? and : [and];
    and = and.map(v => v.replace(/\s+/g, '%20'));
  } else if (isExactMatch(key)) {
    const containsNoSpace = and.search(/\s/) === -1;
    const containsEscapedSpace = and.search(/\\\s/) !== -1;
    if (containsNoSpace || containsEscapedSpace) {
      and = [encodeStr(and)];
    } else {
      and = [`%22${encodeStr(and)}%22`];
    }
  } else {
    and = and.match(spaceTokenizerRegExp).map(t => encodeStr(t));
  }
  return and.length === 1 ? `${and[0]}${boost}` : `(${and.join(`${boost}+AND+`)}${boost})`;
};

const toDateRange = (from, to) => `[${from ? from.toISOString() : '*'} TO ${to ? to.toISOString() : '*'}]`;
const toRange = (from, to) => `[${from || '*'} TO ${to || '*'}]`;

/**
 *
 * @param struct
 * @param isAnd
 * @return {string}
 */
const buildQuery = (struct, isAnd) => {
  const terms = [];
  Object.keys(struct).forEach((key) => {
    let val = struct[key];
    const valueIsArray = Array.isArray(val);
    if (valueIsArray || typeof val === 'string') {
      val = valueIsArray ? val.map(v => namespaces.expand(v)) : namespaces.expand(val);
    }
    switch (key) {
      case 'not':
        terms.push(`NOT(${buildQuery(val, false)})`);
        break;
      case 'or':
        terms.push(buildQuery(val, false));
        break;
      case 'and':
        terms.push(buildQuery(val, true));
        break;
      default:
        if (typeof val === 'string') {
          terms.push(`${key}:${solrFriendly(key, val)}`);
        } else if (Array.isArray(val)) {
          const or = [];
          val.forEach((o) => {
            or.push(`${key}:${solrFriendly(key, o)}`);
          });
          if (or.length > 1) {
            terms.push(`(${or.join('+OR+')})`);
          } else {
            terms.push(`${or.join('+OR+')}`);
          }
        } else if (typeof val === 'object') {
          // TODO
        }
    }
  });
  if (terms.length > 1) {
    return `(${terms.join(isAnd ? '+AND+' : '+OR+')})`;
  }
  return terms.join(`${isAnd ? '+AND+' : '+OR+'}`);
};

/**
 * The SolrQuery class provides a way to create a query by chaining method calls according to
 * the builder pattern. For example:
 *
 *     const sq = es.newSolrQuery().title("some title").rdfType("http://example.com/Person")
 *
 * The example yields a search for entries that have a title that contains "some title"
 * and a rdf:type of "http://example.com/Person" expressed in the metadata.
 * To execute the query you can either ask for a {@link SearchList} and then call
 * getEntries (or forEach):
 *
 *     const sl = sq.list();
 *     sl.getEntries().then((entryArr) => {// Do something })
 *
 * Or you use the abbreviated version where you just call getEntries directly (or forEach)
 * on the SolrQuery:
 *
 *     sq.getEntries()
 *
 * The majority of the methods work the same way, that is they take two values, a value and a
 * possible negation flag. The value can be an array corresponding to a disjunction and if the
 * flag is set true the search string will be constructed to search for the negation of the
 * provided value. For example, if a graph type in the form of an array containing List and User
 * is provided together with a negation boolean set to true, the query will search for anything
 * but lists and users:
 *
 *     sq.graphType([types.GT_LIST, types.GT_USER], true)
 *
 * Supported methods on the solr object correspond in large to the available solr fields
 * documented at, some method names are different to avoid dots:
 * {@link https://code.google.com/p/entrywiki/KnowledgeBaseSearch}
 *
 * There is also a special method ({@link SolrQuery#getQuery getQuery}) for getting the
 * query as a string that is used by EntryStore API behind the scenes, you can safely ignore
 * this method.
 *
 * @exports store/SolrQuery
 */
export default class SolrQuery {

  /**
   * @param {EntryStore} entrystore
   */
  constructor(entrystore) {
    this._entrystore = entrystore;
    this.properties = [];
    this.relatedProperties = [];
    /**
     *
     * @type {Map<string, *>}
     */
    this.params = new Map();
    /**
     *
     * @type {Map<string, any>}
     */
    this.modifiers = new Map();
    /**
     *
     * @type {Set<Object>}
     * @private
     */
    this._and = new Set();
    /**
     *
     * @type {Set<Object>}
     * @private
     */
    this._or = new Set();
    this.facetpredicates = {};
    this.relatedFacetpredicates = {};
    this.facetConfig = {};
  }

  /**
   *
   * @param key
   * @param val
   * @param modifier
   * @returns {SolrQuery}
   * @private
   */
  _q(key, val, modifier = null) {
    this.params.set(key, val);
    if (modifier !== null) {
      this.modifiers.set(key, modifier);
    }
    return this;
  }

  /**
   * Matches the profile.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  profile(val, modifier = null) {
    return this._q('profile', val, modifier);
  }

  /**
   * Matches all titles in all languages, multivalued, cannot be sorted on.
   * Includes dc:title, dcterms:title, skos:prefLabel, skos:altLabel, skos:hiddenLabel,
   * rdfs:label, foaf:name.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  title(val, modifier = null) {
    return this._q('title', val, modifier);
  }

  /**
   * Matches all descriptions in all languages, multivalued, cannot be sorted on.
   * Includes dc:description, dcterms:description, rdfs:comment
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  description(val, modifier = null) {
    return this._q('description', val, modifier);
  }

  /**
   * Matches the username.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  username(val, modifier = null) {
    return this._q('username', val, modifier);
  }

  /**
   * Matches all tags literals in all languages, multivalued, cannot be sorted on.
   * Includes dc:subject, dcterms:subject, dcat:keyword and lom:keyword
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  tagLiteral(val, modifier = null) {
    return this._q('tag.literal', val, modifier);
  }

  /**
   * Matches all tag URIs, multivalued, cannot be sorted on.
   * Includes dc:subject, dcterms:subject
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  tagURI(val, modifier = null) {
    return this._q('tag.uri', val, modifier);
  }

  /**
   * Matches the language (as a literal) of the resource, single value, can be used for sorting?
   * Includes dc:language, dcterms:language
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  lang(val, modifier = null) {
    return this._q('lang', val, modifier);
  }

  /**
   * Matches title, description and tags, multivalue, cannot be sorted on.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  all(val, modifier = null) {
    return this._q('all', val, modifier);
  }

  /**
   * Matches all URIs in subject position in the metadata, except the resourceURI.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  subject(val, modifier = null) {
    return this._q('metadata.subject', val, modifier);
  }

  /**
   * Matches all URIs in predicate position in the metadata.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  predicate(val, modifier = null) {
    return this._q('metadata.predicate', val, modifier);
  }

  /**
   * Matches all literals in object position in the metadata.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  objectLiteral(val, modifier = null) {
    return this._q('metadata.object.literal', val, modifier);
  }

  /**
   * Matches all URIs in object position in the metadata.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  objectUri(val, modifier = null) {
    return this._q('metadata.object.uri', val, modifier);
  }

  /**
   * Matches the resourceURI of the entry.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  resource(val, modifier = null) {
    return this._q('resource', val, modifier);
  }

  /**
   * Matches the entryURI of the entry.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  uri(val, modifier = null) {
    return this._q('uri', val, modifier);
  }

  /**
   * Matches all types of the resourceURI, i.e.
   * all URIs pointed to via rdf:type from the resourceURI.
   *
   * @param {string|array} rdfType
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  rdfType(rdfType, modifier = null) {
    if (Array.isArray(rdfType)) {
      return this._q('rdfType', rdfType.map(t => namespaces.expand(t)), modifier);
    }
    return this._q('rdfType', namespaces.expand(rdfType), modifier);
  }

  /**
   * Matches all creators (in the entry information graph) expressed via their resourceURIs.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  creator(val, modifier = null) {
    return this._q('creator', val, modifier);
  }

  /**
   * Matches all contributors (in the entry information graph) expressed via their resourceURIs.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  contributors(val, modifier = null) {
    return this._q('contributors', val, modifier);
  }

  /**
   * Matches only entries that are part of the given lists, identified via their resourceURIs.
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  lists(val, modifier = null) {
    return this._q('lists', val, modifier);
  }

  /**
   * Matches entries that are created at a specific date or in a range.
   * Ranges must be given as strings as [2010-01-01T00:00:00Z TO *].
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  created(val, modifier = null) {
    return this._q('created', val, modifier);
  }

  /**
   * Utility function to create a range expression for the created function.
   *
   * @param {Date} from - no lower range restriction if undefined or null is passed
   * @param {Date} to - no upper range restriction if undefined or null is passed
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  createdRange(from, to, modifier = null) {
    return this._q('created', toDateRange(from, to), modifier);
  }

  /**
   * Matches entries that are modified at a specific date or in a range.
   * Ranges are given as strings as [2010-01-01T00:00:00Z TO *].
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  modified(val, modifier = null) {
    return this._q('modified', val, modifier);
  }

  /**
   * Utility function to create a range expression for the modified function.

   *
   * @param {Date} from - no lower range restriction if undefined or null is passed
   * @param {Date} to - no upper range restriction if undefined or null is passed
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  modifiedRange(from, to, modifier = null) {
    return this._q('modified', toDateRange(from, to), modifier);
  }

  /**
   * Matches entries with the given entry type, use the values in {@link types}, e.g.
   * sq.entryType(types.ET_LINK).
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  entryType(val, modifier = null) {
    return this._q('entryType', val, modifier);
  }

  /**
   * Matches entries with the given graph type, use the values in {@link types}, e.g.
   * sq.entryType(types.GT_USER).
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  graphType(val, modifier = null) {
    return this._q('graphType', val, modifier);
  }

  /**
   * Matches entries with the given resource type, use the values in {@link types}, e.g.
   * sq.entryType(types.RT_INFORMATIONRESOURCE).
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  resourceType(val, modifier = null) {
    return this._q('resourceType', val, modifier);
  }

  /**
   * Matches only public entries. Warning, individual entrys public flag is inherited from
   * surrounding context and if the context ACL is updated the entrys are not reindexed
   * automatically. Hence, this flag may be incorrect.
   *
   * @param {true|false} isPublic
   * @return {SolrQuery}
   */
  publicRead(isPublic = true) {
    return this._q('public', isPublic === true ? 'true' : 'false');
  }

  /**
   * Matches only entries with explicitly ACL stating user(s) has admin rights
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  admin(val, modifier = null) {
    return this._q('acl.admin', val, modifier);
  }

  /**
   * Matches only entries with explicitly ACL stating user(s) has metadata read rights
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  metadataRead(val, modifier = null) {
    return this._q('acl.metadata.r', val, modifier);
  }

  /**
   * Matches only entries with explicitly ACL stating user(s) has metadata write (and read) rights
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  metadataWrite(val, modifier = null) {
    return this._q('acl.metadata.rw', val, modifier);
  }

  /**
   * Matches only entries with explicitly ACL stating user(s) has resource read rights
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  resourceRead(val, modifier = null) {
    return this._q('acl.resource.r', val, modifier);
  }

  /**
   * Matches only entries with explicitly ACL stating user(s) has resource write (and read) rights
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  resourceWrite(val, modifier = null) {
    return this._q('acl.resource.rw', val, modifier);
  }

  /**
   * Matches entries with with specific status (expressed in entry information graph)
   *
   * @param {string|array} val
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  status(val, modifier = null) {
    return this._q('status', val, modifier);
  }

  /**
   * Matches only entries within specified context(s)
   *
   * @param {string|Context} context either a contextId, the resourceURI for a
   +     * context, a Context instance or an array containing any of those. In case of a
   +     * string, either directly or within the array and it starts with 'http' it is assumed it is
   +     * the resourceURI of the context, otherwise the context is assumed to be a contextId.
   * @param {true|false|string} modifier
   * @return {SolrQuery}
   */
  context(context, modifier = null) {
    const f = (c) => {
      if (c && c.getResourceURI) {
        return c.getResourceURI();
      } else if (typeof c === 'string' && c !== '') {
        if (c.indexOf('http') === 0) {
          return c;
        }
        return this._entrystore.getContextById(c).getResourceURI();
      }
      return null;
    };

    if (Array.isArray(context)) {
      const resourceURIArr = context.map(f).filter(v => v !== null);
      if (resourceURIArr.length > 0) {
        this._q('context', resourceURIArr, modifier);
      }
    } else {
      const resourceURI = f(context);
      if (resourceURI !== null) {
        return this._q('context', resourceURI, modifier);
      }
    }

    return this;
  }

  /**
   * Provide a query in the form of an object structure where the toplevel attributes
   * are disjunctive (OR:ed together). The following example will query for things that
   * are typed as vedgetables AND have the word 'tomato' in either the title OR description:
   * query.rdfType('ex:Vedgetable).or({
   *   title: 'tomato',
   *   description: 'tomato'
   * });
   *
   * Note, the name of the method ('or') does not refers to how the object structure is
   * combined with the rest of the query, only how the inner parts of the object structure
   * is combined. To change the toplevel behaviour of the query from an and to an or,
   * use the disjunctive method.
   *
   * @param {Object} structure
   * @return {SolrQuery}
   */
  or(structure) {
    this._or.add(structure);
    return this;
  }

  /**
   * Provide a query in the form of an object structure where the toplevel attributes
   * are conjunctive (AND:ed together). The following example will query for things that
   * are typed as vedgetables OR typed as fruit AND has a title that contains the word 'orange':
   * query.disjunctive().rdfType('ex:Vedgetable).and({
   *   rdfType: 'ex:Fruit',
   *   title: 'Orange',
   * });
   *
   * Note, the name of the method ('and') does not refers to how the object structure is
   * combined with the rest of the query, only how the inner parts of the object structure
   * is combined. In this example we have change the toplevel behaviour of the query to
   * become disjunctive (being OR:ed together), this is to make the query more representative
   * since there is no need for the grouping of the object structure otherwise.
   *
   * @param {Object} structure
   * @return {SolrQuery}
   */
  and(structure) {
    this._and.add(structure);
    return this;
  }

  /**
   * @deprecated
   */
  //eslint-disable-next-line
  title_lang(title, language) {
  }

  /**
   * If a title has a language set, a dynamic field is created with the pattern "title.en",
   * without multi value support. This is used in the context of sorting.
   * @param title {String} the title to search for
   * @param language {String} the language of the title for instance "en".
   * @return {SolrQuery}
   */
  titleWithLanguage(title, language) {
    this._title_lang = { value: title, language };
    return this;
  }

  /**
   * Matches specific property value combinations.
   *
   * @param {string} predicate
   * @param {string|array} object
   * @param {true|false|string} modifier
   * @param {text|string} [indexType=ngram] 'ngram' corresponds to partial string
   * matching, 'string' corresponds to exact string matching and 'text' corresponds to word matching.
   * @param {boolean} [related=false] will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  literalProperty(predicate, object, modifier, indexType = 'ngram', related = false) {
    const key = shorten(predicate);
    let nodetype;
    switch (indexType) {
      case 'text':
        nodetype = 'literal_t';
        break;
      case 'string':
        nodetype = 'literal_s';
        break;
      case 'ngram':
      default:
        nodetype = 'literal';
    }
    (related ? this.relatedProperties : this.properties).push({
      md5: key,
      pred: predicate,
      object,
      modifier,
      nodetype,
    });
    return this;
  }

  /**
   * Utility function for creating a range for literalProperty.
   *
   * @param {string} predicate
   * @param {string} from
   * @param {string} to
   * @param {true|false|string} modifier
   * @param {boolean} [related=false] will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  literalPropertyRange(predicate, from, to, modifier, related = false) {
    return this.literalProperty(predicate, toRange(from, to), modifier, 'string', related);
  }

  /**
   * Matches specific property value combinations when the value is an integer.
   * Note that the integer values are single value per property and can be used for sorting.
   * Ranges are allowed as strings, for instance [0 TO 100] or [0 TO *] for all positive integers.
   *
   * @param {string} predicate
   * @param {string|array} object
   * @param {true|false|string} modifier
   * @param {boolean} related - will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  integerProperty(predicate, object, modifier, related = false) {
    const key = shorten(predicate);
    (related ? this.relatedProperties : this.properties).push({
      md5: key,
      pred: predicate,
      object,
      modifier,
      nodetype: 'integer',
    });
    return this;
  }

  /**
   * Utility function for creating a integer range for integerProperty.
   *
   * @param {string} predicate
   * @param {string|number} from - if undefined no lower bound will be created, corresponds to *
   * @param {string|number} to - if undefined no upper bound will be created, corresponds to *
   * @param {boolean} related - will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  integerPropertyRange(predicate, from, to, modifier, related = false) {
    return this.integerProperty(predicate, toRange(from, to), modifier, related);
  }

  /**
   * Matches specific property value combinations when the value is an integer.
   * Note that the integer values are single value per property and can be used for sorting.
   * Ranges are allowed as strings, for instance [* TO 2010-01-01T00:00:00Z].
   *
   * @param {string} predicate
   * @param {string|array} object
   * @param {true|false|string} modifier
   * @param {boolean} related - will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  dateProperty(predicate, object, modifier, related = false) {
    const key = shorten(predicate);
    (related ? this.relatedProperties : this.properties).push({
      md5: key,
      pred: predicate,
      object,
      modifier,
      nodetype: 'date',
    });
    return this;
  }

  /**
   * Utility function for creating a date range for dateProperty.
   *
   * @param {string} predicate
   * @param {Date} from
   * @param {Date} to
   * @param {true|false|string} modifier
   * @param {boolean} related - will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  datePropertyRange(predicate, from, to, modifier, related = false) {
    return this.dateProperty(predicate, toDateRange(from, to), modifier, related);
  }

  /**
   * Matches specific property value combinations when the value is an uri.
   *
   * @param {string} predicate
   * @param {string|array} object
   * @param {true|false|string} modifier
   * @param {boolean} related - will search in related properties if true, default is false
   * @return {SolrQuery}
   */
  uriProperty(predicate, object, modifier, related = false) {
    const key = shorten(predicate);

    (related ? this.relatedProperties : this.properties).push({
      md5: key,
      pred: predicate,
      object: Array.isArray(object) ? object.map(o => namespaces.expand(o)) :
        namespaces.expand(object),
      modifier,
      nodetype: 'uri',
    });
    return this;
  }

  /**
   * Sets the pagination limit.
   *
   * @param {string|number} limit
   * @return {SolrQuery}
   */
  limit(limit) {
    this._limit = limit;
    return this;
  }

  /**
   * Gets the pagination limit if it set.
   *
   * @return {string|number}
   */
  getLimit() {
    return this._limit;
  }

  /**
   * The parameter "sort" can be used for Solr-style sorting, e.g. "sort=title+asc,modified+desc".
   * The default sorting value is to sort after the score (relevancy) and the modification date.
   * All string and non-multi value fields can be used for sorting, this basically excludes title,
   * description and keywords,
   * but allows sorting after e.g. title.en.
   * If no sort is explicitly given the default sort string used is "score+asc".
   * @param sort {String} a list of fields together with '+asc' or '+desc', first field has the
   * highest priority when sorting.
   * @return {SolrQuery}
   */
  sort(sort) {
    this._sort = sort;
    return this;
  }

  /**
   * Set an explicit offset.
   *
   * @param {string|number} offset
   * @return {SolrQuery}
   */
  offset(offset) {
    this._offset = offset;
    return this;
  }

  /**
   * @private
   * @param {string} facet
   * @param {string} predicate
   * @param {boolean} [related=false]
   * @return {SolrQuery}
   */
  facet(facet, predicate, related = false) {
    this.facets = this.facets || [];
    if (predicate) {
      this.facet2predicate = this.facet2predicate || {};
      this.facet2predicate[facet] = namespaces.expand(predicate);
      if (related) {
        this.relatedFacetpredicates[predicate] = true;
      } else {
        this.facetpredicates[predicate] = true;
      }
    }
    this.facets.push(facet);
    return this;
  }

  /**
   * Request to include literal facets for the given predicate
   * @param {string} predicate
   * @param {boolean} [related=false] whether the facet is on the related predicates, default is false
   * @return {SolrQuery}
   */
  literalFacet(predicate, related = false) {
    this.facet(`${related ? 'related.' : ''}metadata.predicate.literal_s.${shorten(predicate)}`, predicate, related);
    return this;
  }

  /**
   * Request to include URI facets for the given predicate
   * @param {string} predicate
   * @param {boolean} [related=false] whether the facet is on the related predicates, default is false
   * @return {SolrQuery}
   */
  uriFacet(predicate, related = false) {
    this.facet(`${related ? 'related.' : ''}metadata.predicate.uri.${shorten(predicate)}`, predicate, related);
    return this;
  }

  /**
   * Request to include integer facets for the given predicate
   * @param {string} predicate
   * @param {boolean} [related=false] whether the facet is on the related predicates, default is false
   * @return {SolrQuery}
   */
  integerFacet(predicate, related = false) {
    this.facet(`${related ? 'related.' : ''}metadata.predicate.integer.${shorten(predicate)}`, predicate, related);
    return this;
  }

  /**
   * The maximum amount of facet results to be report on. Default is 100, values up to 1000 is allowed.
   * @param {integer} limit maximum number of facet results to return per facet.
   */
  facetLimit(limit) {
    this.facetConfig.facetLimit = limit;
  }

  /**
   * The minimum amount of facet results to be included in the response. Default is 1.
   * @param {integer} limit minumum amount of facet results, facets with fewer results will be omitted in the response.
   */
  facetMinCount(limit) {
    this.facetConfig.facetMinCount = limit;
  }

  /**
   * If matches with no match against a facet should be reported as well.
   * @param {boolean} include true if they should be reported.
   */
  facetMissing(include) {
    this.facetConfig.facetMissing = include;
  }

  /**
   * Tell the query construction to make the fields added via the property methods
   * (uriProperty, literalProperty and integerProperty) to be disjunctive rather than
   * conjunctive. For example:
   *
   *     es.newSolrQuery().disjunctiveProperties().literalProperty("dcterms:title", "banana")
   *          .uriProperty("dcterms:subject", "ex:Banana");
   *
   * Will search for entries that have either a "banana" in the title or a relation to
   * ex:Banana via dcterms:subject. The default, without disjunctiveProperties being called
   * is to create a conjunction, i.e. AND them together.
   *
   * @return {SolrQuery}
   */
  disjunctiveProperties() {
    this._disjunctiveProperties = true;
    return this;
  }

  /**
   * Tell the query construction to make top level fields disjunctive rather than
   * conjunctive. For example
   *
   *     es.newSolrQuery().disjunctive().title("banana").description("tomato")
   *
   * Will search for entries that have either a "banana" in the title or "tomato" in the
   * description rather than entries that have both which is the default.
   *
   * @return {SolrQuery}
   */
  disjunctive() {
    this._disjunctive = true;
    return this;
  }

  /**
   * Construct a SearchList fro this SolrQuery.
   *
   * @param asyncCallType
   * @returns {SearchList}
   */
  list(asyncCallType) {
    return new SearchList(this._entrystore, this, asyncCallType);
  }

  /**
   * Produces the actual query to the EntryStore API.
   * @return {string}
   * @protected
   */
  getQuery() {
    const and = [];
    if (this._title_lang != null) {
      and.push(`title.${this._title_lang.lang}:${solrFriendly(this._title_lang.lang,
        this._title_lang.value)}`);
    }

    this.params.forEach((v, key) => {
      const modifier = this.modifiers.get(key);
      if ((typeof v === 'string') && v !== '') {
        if (modifier === true || modifier === 'not') {
          and.push(`NOT(${key}:${solrFriendly(key, v)})`);
        } else {
          and.push(`${key}:${solrFriendly(key, v)}`);
        }
      } else if (Array.isArray(v) && v.length > 0) {
        const or = [];
        v.forEach((ov) => {
          if ((typeof ov === 'string')) {
            or.push(`${key}:${solrFriendly(key, ov)}`);
          }
        });
        if (modifier === true || modifier === 'not') {
          and.push(`NOT(${or.join('+OR+')})`);
        } else if (modifier === 'and') {
          and.push(`(${or.join('+AND+')})`);
        } else {
          and.push(`(${or.join('+OR+')})`);
        }
      }
    });

    if (this.relatedProperties.length > 0) {
      const or = [];
      this.relatedProperties.forEach((prop) => {
        const obj = prop.object;
        const key = `related.metadata.predicate.${prop.nodetype}.${prop.md5}`;
        if (typeof obj === 'string') {
          or.push(`${key}:${solrFriendly(key, obj, this.relatedFacetpredicates[prop.pred])}`);
        } else if (Array.isArray(obj) && obj.length > 0) {
          obj.forEach((o) => {
            or.push(`${key}:${solrFriendly(key, o, this.relatedFacetpredicates[prop.pred])}`);
          });
        }
      });
      and.push(`(${or.join('+OR+')})`);
    }
    if (this._disjunctiveProperties || this._disjunctive) {
      const or = [];
      this.properties.forEach((prop) => {
        const obj = prop.object;
        const key = `metadata.predicate.${prop.nodetype}.${prop.md5}`;
        if (typeof obj === 'string') {
          or.push(`${key}:${solrFriendly(key, obj, this.facetpredicates[prop.pred])}`);
        } else if (Array.isArray(obj) && obj.length > 0) {
          obj.forEach((o) => {
            or.push(`${key}:${solrFriendly(key, o, this.facetpredicates[prop.pred])}`);
          });
        }
      });
      if (or.length > 0) {
        and.push(`(${or.join('+OR+')})`);
      }
    } else {
      this.properties.forEach((prop) => {
        const obj = prop.object;
        const key = `metadata.predicate.${prop.nodetype}.${prop.md5}`;
        if (typeof obj === 'string') {
          if (prop.modifier === true || prop.modifier === 'not') {
            and.push(`NOT(${key}:${solrFriendly(key, obj, this.facetpredicates[prop.pred])})`);
          } else {
            and.push(`${key}:${solrFriendly(key, obj, this.facetpredicates[prop.pred])}`);
          }
        } else if (Array.isArray(obj) && obj.length > 0) {
          const or = [];
          obj.forEach((o) => {
            or.push(`${key}:${solrFriendly(key, o, this.facetpredicates[prop.pred])}`);
          }, this);
          if (prop.modifier === true || prop.modifier === 'not') {
            and.push(`NOT(${or.join('+OR+')})`);
          } else if (prop.modifier === 'and') {
            and.push(`(${or.join('+AND+')})`);
          } else {
            and.push(`(${or.join('+OR+')})`);
          }
        }
      }, this);
    }
    this._and.forEach((struct) => {
      and.push(buildQuery(struct, true));
    });
    this._or.forEach((struct) => {
      and.push(buildQuery(struct, false));
    });

    let trail = '';
    if (this._limit != null) {
      trail = `&limit=${this._limit}`;
    }
    if (this._offset) {
      trail = `${trail}&offset=${this._offset}`;
    }
    if (this._sort) {
      trail = `${trail}&sort=${this._sort || 'score+asc'}`;
    }
    if (this.facets) {
      trail += `&facetFields=${this.facets.join(',')}`;
    }
    if (this.facetConfig.facetLimit !== undefined) {
      trail += `&facetLimit=${this.facetConfig.facetLimit}`;
    }
    if (this.facetConfig.facetMinCount !== undefined) {
      trail += `&facetMinCount=${this.facetConfig.facetMinCount}`;
    }
    if (this.facetConfig.facetMissing === true) {
      trail += `&facetMissing=true`;
    }
    return `${this._entrystore.getBaseURI()}search?type=solr&query=${and.join(this._disjunctive ? '+OR' : '+AND+')}${trail}`;
  }

  /**
   * @param page
   * @returns {Promise.<Array.<Entry>>} the promise will return an entry-array.
   * @see {List.getEntries}
   */
  getEntries(page) {
    return this.list().getEntries(page);
  }

  /**
   * Finds a single entry for the current query (sets the limit to 1).
   * @returns {Promise<Entry>}
   */
  async getEntry() {
    this.limit(1);
    const entries = await this.getEntries(0);
    return entries[0];
  }

  /**
   * @param func
   * @return {promise}
   * @see {List.forEach}
   */
  forEach(func) {
    return this.list().forEach(func);
  }

  /**
   * Executes the query with limit 0 and returns the number of matches for the current query.
   *
   * @returns {Promise<number>}
   */
  async size() {
    this.limit(0);
    const list = this.list();
    await list.getEntries();
    return list.getSize();
  }
}