Source: src/js/common/QueryService.js

"use strict";

define(["jquery"], ($) => {
  /**
   *
   * @typedef {object} QueryOptions
   * @property {string}  q Solr query (Lucene syntax). Default `*:*`.
   * @property {string[]|string} [filterQueries] Filter queries (fq) to apply.
   * @property {string[]|string} [fields] Fields to return (fl).
   * @property {string} [sort] Sort clause, e.g., `'dateUploaded desc'`.
   * @property {number} [rows] Result row count. Default `25`.
   * @property {number} [start] Result offset. Default `0`.
   * @property {string[]} [facets] Fields to facet on.
   * @property {string[]} [facetQueries] Facet queries (fq) to apply.
   * @property {string[]} [statsFields] Fields for statistics (stats.field).
   * @property {string} [facetQueries] Facet queries (facet.query) to apply.
   * @property {number} [facetLimit] Default `-1` (no limit).
   * @property {number} [facetMinCount] Default `1`.
   * @property {} [facetRange]
   * @property {string} [facetRangeStart]
   * @property {string} [facetRangeEnd]
   * @property {string} [facetRangeGap]
   * @property {string} [urlBase] Optional override for the query service base
   * URL.
   * @property {boolean} [usePost] Force POST / GET (overrides auto-choice).
   * @property {boolean} [useAuth=true] Inject MetacatUI auth headers?
   * @property {boolean} [archived] Include archived items? Default `false`.
   * @property {boolean} [group] Use Solr grouping (group=true)?
   * @property {string} [groupField] Field to group by (if `group` is true).
   * @property {number} [groupLimit] Limit of groups to return (if `group` is
   * true).
   * @property {Array.<Array.<string>>} [extraParams] Extra arbitrary parameters
   * to include in the query. Formatted as key-value pairs in an array, e.g.,
   * `[['key', 'val'], ['key2', 'val2']]`.
   * @property {boolean} [disableQueryPOSTs] Disable POST requests for queries?
   */

  /**
   * QueryService provides methods to execute Solr queries against the
   * configured query service URL. It supports both GET and POST requests,
   * handles query parameters, and can include facets, filter queries, and
   * statistics fields.
   * @class QueryService
   */
  class QueryService {
    // --------------------------------------------------------------------- //
    //  Public API
    // --------------------------------------------------------------------- //

    /**
     * Common logic to extract query configuration from options.
     * @param {QueryOptions} opts
     * @returns {object} { queryParams, urlBase, shouldPost }
     */
    static getQueryConfig(opts = {}) {
      let {
        q = "*:*",
        filterQueries = [],
        fields = [],
        sort = "dateUploaded desc",
        rows = 25,
        start = 0,
        facets = [],
        facetQueries = [],
        statsFields = [],
        facetLimit = -1,
        facetMinCount = 1,
        facetRange,
        facetRangeStart,
        facetRangeEnd,
        facetRangeGap,
        urlBase: urlBaseOverride,
        extraParams = [],
        usePost,
        archived = false,
        group = false,
        groupField,
        groupLimit,
      } = opts;

      // Normalize q and other query-like inputs if callers provided URL-encoded strings
      q = QueryService.normalizeLucene(q, { label: "q" });
      const normFilterQueries = []
        .concat(filterQueries)
        .filter(Boolean)
        .map((fq) =>
          QueryService.normalizeLucene(fq, { label: "filterQueries" }),
        );
      const normFacetQueries = []
        .concat(facetQueries)
        .filter(Boolean)
        .map((fq) =>
          QueryService.normalizeLucene(fq, { label: "facetQueries" }),
        );

      const endpoint = urlBaseOverride || QueryService.queryServiceUrl();
      const urlBase = endpoint.replace(/\?$/, "");
      const queryParams = QueryService.buildQueryObject({
        q,
        filterQueries: normFilterQueries,
        fields,
        sort,
        rows,
        start,
        facets,
        facetQueries: normFacetQueries,
        statsFields,
        facetLimit,
        facetMinCount,
        facetRange,
        facetRangeStart,
        facetRangeEnd,
        facetRangeGap,
        extraParams,
        archived,
        group,
        groupField,
        groupLimit,
      });

      const shouldPost = QueryService.decidePost({
        explicit: usePost,
      });

      return { queryParams, urlBase, shouldPost };
    }

    /**
     * Execute a Solr query using the native Fetch API. Returns a Promise
     * resolving to the parsed JSON response.
     * NOTE: This method is not jqXHR-compatible and should not be used in
     * Backbone sync/fetch overrides.
     * @param {QueryOptions} opts Query parameters & flags.
     * @returns {Promise<object>} A promise resolving to the JSON result.
     * @throws {Error} On network failure or non-2xx status.
     */
    static async queryWithFetch(opts = {}) {
      const config = QueryService.getQueryConfig(opts);
      const { queryParams, shouldPost } = config;
      let { urlBase } = config;

      let fetchOptions = {
        method: shouldPost ? "POST" : "GET",
        headers: {},
      };

      if (shouldPost) {
        const fd = new FormData();
        Object.entries(queryParams).forEach(([k, v]) => {
          // TODO: Handle groups and other complex types if needed.... make a separate method?
          if (Array.isArray(v)) {
            v.forEach((item) => fd.append(k, item));
          } else {
            fd.append(k, v);
          }
        });
        fetchOptions.body = fd;
      } else {
        const qs = QueryService.toQueryString(queryParams);
        urlBase += (urlBase.includes("?") ? "" : "?") + qs;
      }

      if (opts.useAuth !== false) {
        fetchOptions = {
          ...fetchOptions,
          ...MetacatUI.appUserModel.createFetchSettings(),
        };
      }

      const res = await fetch(urlBase, fetchOptions);
      if (!res.ok) {
        throw new Error(
          `QueryService.queryWithFetch(): HTTP ${res.status}`,
          res,
        );
      }
      return res.json();
    }

    /**
     * Execute a Solr query and obtain the raw JSON response.
     * @param {QueryOptions} opts Query parameters & flags.
     * @returns {jqXHR} jQuery AJAX promise.
     */
    static query(opts = {}) {
      const { queryParams, urlBase, shouldPost } =
        QueryService.getQueryConfig(opts);

      let ajaxSettings = shouldPost
        ? QueryService.buildPostSettings(urlBase, queryParams)
        : QueryService.buildGetSettings(urlBase, queryParams);

      if (opts.useAuth !== false) {
        ajaxSettings = QueryService.mergeAuth(ajaxSettings);
      }

      return $.ajax(ajaxSettings);
    }

    /**
     * Build query parameters as a plain object (useful for tests or logging).
     * @param {QueryOptions} opts Query options.
     * @returns {object} Query parameters object.
     */
    static buildQueryParams(opts = {}) {
      return QueryService.buildQueryObject(opts);
    }

    /**
     * Perform basic clean up of a Solr response JSON, including removing
     * empty values and trimming resourceMap fields.
     * @param {object} response The Solr response JSON object.
     * @returns {object[]} The cleaned-up array of documents (docs).
     */
    static parseResponse(response) {
      // If the response is not an object, cannot parse it
      if (typeof response !== "object" || !response?.response) {
        throw new Error(
          "QueryService.parseResponse(): Response is not a valid object.",
        );
      }

      if (
        !Array.isArray(response.response.docs) ||
        !response.response.docs?.length
      ) {
        // If there are no docs, return an empty array
        return [];
      }

      const docs = response.response.docs;
      QueryService.removeEmptyValues(docs);
      QueryService.parseResourceMapFields(docs);

      // Default to returning the raw response
      return docs;
    }

    /**
     * Parses the resourceMap fields from the Solr response JSON.
     * @param {object[]} json - The "docs" part of a JSON object from the Solr
     * response
     * @returns {object[]} The updated docs with parsed resourceMap fields,
     * though the original docs are modified in place.
     */
    static parseResourceMapFields(docs) {
      if (!Array.isArray(docs) || !docs.length) {
        return [];
      }
      docs.forEach((doc) => {
        if (doc.resourceMap) {
          doc.resourceMap = QueryService.parseResourceMapField(doc);
        }
      });
      return docs;
    }

    /**
     * Builds a common query for a PID and optional SID. The query will search
     * for the PID in either the `id` or `seriesId` fields, and if a SID is
     * provided, it will also filter by that. If no PID is provided, it will
     * search for the SID in the `seriesId` field, excluding any versions that
     * have been obsoleted.
     * @param {string} pid - The PID to search for.
     * @param {string} sid - The series ID to search for.
     * @returns {string} The constructed query string.
     */
    static buildIdQuery(pid, sid) {
      let query = "";
      const getQueryPart = QueryService.getQueryPart;
      // If there is no pid set, then search for sid or pid
      if (!sid)
        query += `(${getQueryPart("id", pid)} OR ${getQueryPart("seriesId", pid)})`;
      // If a seriesId is specified, then search for that
      else if (sid && pid)
        query += `(${getQueryPart("id", pid)} AND ${getQueryPart("seriesId", sid)})`;
      // If only a seriesId is specified, then just search for the most recent
      // version
      else if (sid && !pid)
        query += `${getQueryPart("seriesId", sid)} -obsoletedBy:*`;
      return query;
    }

    /**
     * Escape special characters in a Lucene query string. Lucene is the query
     * language used by Solr.
     * @param {string} value The string to escape.
     * @returns {string} The escaped string.
     */
    static escapeLucene(value) {
      if (typeof value !== "string") {
        throw new TypeError(
          "QueryService.escapeLucene(): value must be a string.",
        );
      }
      return value.replace(/([+\-!(){}\[\]^"~*?:\\/])/g, "\\$1");
    }

    /**
     * Build a query part for a field and value, escaping the value for Lucene.
     * @param {string} field The field name.
     * @param {string} value The value to search for.
     * @returns {string} The formatted query part, e.g., `field:"value"`.
     */
    static getQueryPart(field, value) {
      if (typeof field !== "string" || typeof value !== "string") {
        throw new TypeError(
          "QueryService.queryPart(): field and value must be strings.",
        );
      }
      return `${field}:"${QueryService.escapeLucene(value)}"`;
    }

    /**
     * Parses the resourceMap field from the Solr response JSON.
     * @param {object} json - The JSON object from the Solr response
     * @returns {string|string[]} The resourceMap parsed. If it is a string,
     * it returns the trimmed string. If it is an array, it returns an array
     * of trimmed strings. If it is neither, it returns an empty array.
     */
    static parseResourceMapField(doc) {
      if (!doc || !doc.resourceMap) {
        return [];
      }
      const rms = doc.resourceMap;
      if (typeof rms === "string") {
        return [rms.trim()];
      } else if (Array.isArray(rms)) {
        return rms
          .map((rMapId) => {
            return typeof rMapId === "string" ? rMapId.trim() : rMapId;
          })
          .filter(Boolean); // Filter out any falsy values
      }
      // If nothing works so far, return an empty array
      return [];
    }

    /**
     * Remove empty values from an array of documents. This modifies the
     * documents in place, removing any properties that are `null`, `undefined`,
     * or an empty string.
     * @param {object[]} docs The array of documents to clean.
     * @returns {object[]} The cleaned array of documents.
     */
    static removeEmptyValues(docs) {
      if (!Array.isArray(docs) || !docs.length) {
        return [];
      }
      docs.forEach((doc) => {
        Object.keys(doc).forEach((key) => {
          if (doc[key] === null || doc[key] === undefined || doc[key] === "") {
            delete doc[key];
          }
        });
      });
      return docs;
    }

    // --------------------------------------------------------------------- //
    //  Private helpers
    // --------------------------------------------------------------------- //

    /**
     * Get the configured query service URL from MetacatUI's appModel. Throws an
     * error if not configured.
     * @returns {string} The query service URL.
     * @throws {Error} If queryServiceUrl is not configured.
     */
    static queryServiceUrl() {
      if (!MetacatUI?.appModel?.get("queryServiceUrl")) {
        throw new Error(
          "QueryService.queryServiceUrl(): queryServiceUrl is not configured.",
        );
      }
      return MetacatUI.appModel.get("queryServiceUrl");
    }

    /**
     * Construct a query object for Solr. Formats the parameters according to
     * Solr's expectations, including facets, filter queries, and stats fields.
     * Applies defaults for missing parameters.
     * @param {QueryOptions} opts Query options.
     * @returns {object} Query parameters object.
     */
    static buildQueryObject({
      q,
      filterQueries,
      fields,
      sort,
      rows,
      start,
      facets,
      facetQueries,
      statsFields,
      facetLimit,
      facetMinCount,
      facetRange,
      facetRangeStart,
      facetRangeEnd,
      facetRangeGap,
      archived,
      group,
      groupField,
      groupLimit,
      extraParams = [],
    }) {
      const params = {
        q,
        rows,
        start,
        wt: "json",
      };

      // fq
      const fqArray = [].concat(filterQueries).flat().filter(Boolean);
      fqArray.forEach((fq) => {
        params.fq = params.fq || [];
        params.fq.push(fq);
      });

      // fl
      if (fields) {
        // If fields is a string, assume it is already formatted
        // as a comma-separated list of fields.
        if (typeof fields === "string") {
          params.fl = fields;
        } else if (Array.isArray(fields)) {
          const fieldsArray = [].concat(fields).flat().filter(Boolean);
          if (fieldsArray.length) {
            params.fl = fieldsArray.join(",");
          }
        }
      }

      // sort
      if (sort) params.sort = sort.replace(/\+/g, " ");

      // facets
      const facetsArray = [].concat(facets).flat().filter(Boolean);
      if (facetsArray.length) {
        params.facet = "true";
        facetsArray.forEach((field) => {
          params["facet.field"] = params["facet.field"] || [];
          params["facet.field"].push(field);
        });
        // IMPORTANT: Do not set a global facet.mincount when a facet range is present,
        // or Solr will drop zero-count buckets from facet.range results. Callers can
        // still use per-field settings via extraParams (e.g., f.<field>.facet.mincount).
        if (!facetRange) {
          params["facet.mincount"] = facetMinCount;
        }
        params["facet.limit"] = facetLimit;
        params["facet.sort"] = "index";
      }

      // facet queries
      const facetQueriesArray = [].concat(facetQueries).flat().filter(Boolean);
      if (facetQueriesArray.length) {
        params.facet = "true";
        facetQueriesArray.forEach((fq) => {
          params["facet.query"] = params["facet.query"] || [];
          params["facet.query"].push(fq);
        });
      }

      // facet range
      if (facetRange) {
        params.facet = "true";
        params["facet.range"] = facetRange;
        if (facetRangeStart) params["facet.range.start"] = facetRangeStart;
        if (facetRangeEnd) params["facet.range.end"] = facetRangeEnd;
        if (facetRangeGap) params["facet.range.gap"] = facetRangeGap;
      }

      // stats
      const statsFieldsArray = [].concat(statsFields).flat().filter(Boolean);
      if (statsFieldsArray.length) {
        params["stats"] = "true";
        statsFieldsArray.forEach((field) => {
          params["stats.field"] = params["stats.field"] || [];
          params["stats.field"].push(field);
        });
      }

      // TODO - are there other values possible for the archived param?
      if (archived) {
        params["archived"] = "archived:*";
      }

      // groups
      if (group) {
        params.group = "true";
        if (groupField) {
          if (typeof groupField === "string") {
            params["group.field"] = groupField;
          } else if (Array.isArray(groupField)) {
            params["group.field"] = groupField.join(",");
          } else {
            throw new TypeError(
              "QueryService.buildQueryObject(): groupField must be a string or array.",
            );
          }
        }
        if (typeof groupLimit === "number") {
          params["group.limit"] = groupLimit;
        }
      }

      // Extra arbitrary params (array of [key,value]) for advanced Solr options
      if (Array.isArray(extraParams) && extraParams.length) {
        extraParams
          .filter(
            (pair) =>
              Array.isArray(pair) &&
              pair.length === 2 &&
              typeof pair[0] === "string" &&
              pair[0].length,
          )
          .forEach(([k, v]) => {
            if (v === undefined || v === null) return;
            // Allow multiple values for same key
            if (params[k]) {
              if (Array.isArray(params[k])) params[k].push(v);
              else params[k] = [params[k], v];
            } else {
              params[k] = v;
            }
          });
      }

      return params;
    }

    /**
     * Detect and normalize a Lucene query component that may be URL-encoded or
     * contain unnecessary escapes such as \/. Safe to call on already
     * normalized strings; returns the input when no changes are needed.
     * @param {string} str The string to normalize.
     * @param {object} opts Options object.
     * @returns {string} The normalized string.
     */
    static normalizeLucene(str, opts = {}) {
      try {
        if (typeof str !== "string" || !str.length) return str;
        const label = opts.label || "";
        let out = str;
        const hadPercent = /%[0-9a-fA-F]{2}/.test(out);
        if (hadPercent) {
          try {
            const decoded = decodeURIComponent(out);
            if (decoded && decoded !== out) {
              // eslint-disable-next-line no-console
              console.warn(
                `QueryService: decoded percent-encoded ${label || "query"}.`,
              );
              out = decoded;
            }
          } catch (e) {
            // eslint-disable-next-line no-console
            console.warn(
              `QueryService: failed to decode percent-encoded ${label || "query"}; using as-is.`,
            );
          }
        }
        // Remove unnecessary escaped slashes in wildcard terms
        if (out.includes("\\/")) {
          out = out.replace(/\\\//g, "/");
        }
        return out.trim();
      } catch (_e) {
        return str;
      }
    }

    /**
     * Decide whether to use POST or GET for the query. If `explicit` is
     * provided, it overrides the auto-decision. If `disableQueryPOSTs` is set,
     * always use GET. Otherwise, default to using POST.
     * @param {boolean|null} explicit Explicitly force POST or GET. If `null`
     * or `undefined`, auto-decide.
     * @returns {boolean} `true` for POST, `false` for GET.
     */
    static decidePost(explicit) {
      if (typeof explicit === "boolean") return explicit;
      if (!!MetacatUI?.appModel?.get("disableQueryPOSTs")) return false;
      return true;
    }

    /**
     * Convert an object to a URL query string. Handles arrays by appending each
     * item with the same key.
     * @param {object} obj The object to convert.
     * @returns {string} The URL-encoded query string.
     */
    static toQueryString(obj) {
      const usp = new URLSearchParams();
      Object.entries(obj).forEach(([k, v]) => {
        if (Array.isArray(v)) {
          v.forEach((item) => usp.append(k, item));
        } else {
          usp.append(k, v);
        }
      });
      return usp.toString();
    }

    /**
     * Build GET ajax settings for a query.
     * @param {string} urlBase The base URL for the query service.
     * @param {object} queryParams The query parameters to include.
     * @returns {object} jQuery AJAX settings object.
     */
    static buildGetSettings(urlBase, queryParams) {
      const qs = QueryService.toQueryString(queryParams);
      const url = urlBase + (urlBase.includes("?") ? "" : "?") + qs;
      return {
        url,
        type: "GET",
        dataType: "json",
      };
    }

    /**
     * Build POST ajax settings for a query.
     * @param {string} urlBase The base URL for the query service.
     * @param {object} queryParams The query parameters to include.
     * @returns {object} jQuery AJAX settings object.
     */
    static buildPostSettings(urlBase, queryParams) {
      const fd = new FormData();
      Object.entries(queryParams).forEach(([k, v]) => {
        if (Array.isArray(v)) {
          v.forEach((item) => fd.append(k, item));
        } else {
          fd.append(k, v);
        }
      });
      return {
        url: urlBase,
        type: "POST",
        data: fd,
        contentType: false,
        processData: false,
        dataType: "json",
      };
    }

    /**
     * Merge authentication settings into AJAX options. If `appUserModel` is not
     * available, returns the original options.
     * @param {object} ajaxOpts The AJAX options to merge with auth.
     * @returns {object} Merged AJAX options with authentication headers.
     */
    static mergeAuth(ajaxOpts) {
      if (!MetacatUI.appUserModel?.createAjaxSettings) return ajaxOpts;
      const auth = MetacatUI.appUserModel?.createAjaxSettings();
      return { ...ajaxOpts, ...auth };
    }
  }

  return QueryService;
});