Source: collection.js

/**
 * Collection envelope, in-memory query execution and RFC 8288 link headers
 * (specification ยง6).
 */

/**
 * Test whether an item satisfies every filter.
 *
 * A filter whose value is a list matches when the item field equals any member,
 * which is what repeated query parameters (`?tag=a&tag=b`) mean.
 *
 * @param {object} item - Candidate item
 * @param {object} filters - Field filters
 * @returns {boolean} True when the item matches
 */
export function matchesFilters(item, filters) {
  return Object.entries(filters).every(([field, expected]) => {
    const actual = item?.[field];
    if (Array.isArray(expected)) {
      return expected.some((candidate) => candidate === actual);
    }
    return actual === expected;
  });
}

/**
 * Compare two values in a total order that works across LINO scalar types.
 *
 * @param {*} left - First value
 * @param {*} right - Second value
 * @returns {number} Negative, zero or positive
 */
function compareValues(left, right) {
  if (left === right) {
    return 0;
  }
  if (left === undefined || left === null) {
    return -1;
  }
  if (right === undefined || right === null) {
    return 1;
  }
  if (typeof left === "number" && typeof right === "number") {
    return left - right;
  }
  return String(left).localeCompare(String(right));
}

/**
 * Sort items by the sort keys of a collection query.
 *
 * @param {object[]} items - Items to sort (not mutated)
 * @param {Array<{field: string, descending: boolean}>} sort - Sort keys
 * @returns {object[]} Sorted copy
 */
export function sortItems(items, sort) {
  if (!sort || sort.length === 0) {
    return items.slice();
  }
  return items.slice().sort((left, right) => {
    for (const { field, descending } of sort) {
      const comparison = compareValues(left?.[field], right?.[field]);
      if (comparison !== 0) {
        return descending ? -comparison : comparison;
      }
    }
    return 0;
  });
}

/**
 * Reduce an item to a sparse fieldset.
 *
 * @param {object} item - Item to project
 * @param {string[]|null} fields - Field names, or null for every field
 * @returns {object} Projected item
 */
export function projectFields(item, fields) {
  if (!fields) {
    return item;
  }
  const projected = {};
  for (const field of fields) {
    if (item && Object.hasOwn(item, field)) {
      projected[field] = item[field];
    }
  }
  return projected;
}

/**
 * Run a parsed collection query against an in-memory list.
 *
 * @param {object[]} items - Every item of the collection
 * @param {object} query - Query from {@link parseCollectionQuery}
 * @returns {{items: object[], page: {limit: number, offset: number, total: number, count: number}}} Collection envelope
 */
export function applyCollectionQuery(items, query) {
  const filtered = items.filter((item) => matchesFilters(item, query.filters));
  const sorted = sortItems(filtered, query.sort);
  const page = sorted.slice(query.offset, query.offset + query.limit);
  const projected = page.map((item) => projectFields(item, query.fields));

  return collectionEnvelope(projected, {
    limit: query.limit,
    offset: query.offset,
    total: filtered.length,
  });
}

/**
 * Wrap items in the collection envelope of the specification.
 *
 * @param {object[]} items - Items of this page
 * @param {{limit: number, offset: number, total: number}} page - Page metadata
 * @returns {{items: object[], page: object}} Collection envelope
 */
export function collectionEnvelope(items, page) {
  return {
    items,
    page: {
      limit: page.limit,
      offset: page.offset,
      total: page.total,
      count: items.length,
    },
  };
}

/**
 * Build the RFC 8288 `Link` header value for a paginated collection.
 *
 * @param {string} path - Request path without a query string
 * @param {object} query - Original query parameters
 * @param {{limit: number, offset: number, total: number}} page - Page metadata
 * @returns {string} `Link` header value ("" when there is nothing to link to)
 */
export function paginationLinkHeader(path, query, page) {
  const { limit, offset, total } = page;
  if (!limit) {
    return "";
  }

  const build = (targetOffset) => {
    const parameters = new URLSearchParams();
    for (const [name, value] of Object.entries(query ?? {})) {
      if (name === "limit" || name === "offset") {
        continue;
      }
      for (const item of Array.isArray(value) ? value : [value]) {
        parameters.append(name, String(item));
      }
    }
    parameters.set("limit", String(limit));
    parameters.set("offset", String(targetOffset));
    return `${path}?${parameters.toString()}`;
  };

  const lastOffset = total === 0 ? 0 : Math.floor((total - 1) / limit) * limit;
  const links = [];

  links.push(`<${build(0)}>; rel="first"`);
  if (offset > 0) {
    links.push(`<${build(Math.max(0, offset - limit))}>; rel="prev"`);
  }
  if (offset + limit < total) {
    links.push(`<${build(offset + limit)}>; rel="next"`);
  }
  links.push(`<${build(lastOffset)}>; rel="last"`);

  return links.join(", ");
}