Functional Weave
Code in Python

collections.diff@1.0.0

impl/typescript.ts

4,816 bytes · the TypeScript implementation · view raw

import { type RecordChange, type RecordDiff } from "./collections_diff_types.ts";

/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */
type DiffRecord = Readonly<Record<string, unknown>>;

/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */
const SAFE_INTEGER = 9007199254740991;

function isMap(value: unknown): value is DiffRecord {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
 * A record's key as text, rendered the way collections.group-by-key renders a
 * group name so the two agree on what "the same key" means. Null when absent.
 */
function keyOf(value: unknown, key: string): string | null {
  if (value === undefined || value === null) return null;
  if (typeof value === "string") return value;
  if (typeof value === "boolean") return value ? "true" : "false";
  if (typeof value === "number") {
    if (!Number.isInteger(value)) {
      throw new TypeError(`cannot diff by the fractional number ${value} at "${key}"`);
    }
    if (Math.abs(value) > SAFE_INTEGER) {
      throw new RangeError(`cannot diff by the out-of-range number ${value} at "${key}"`);
    }
    return String(value);
  }
  throw new TypeError(`cannot diff by the list or map at "${key}"`);
}

/**
 * Deep JSON equality, spelled out so all three languages agree: numbers by
 * value (1 equals 1.0), no coercion between types (true is not 1, "1" is not
 * 1), lists in order, and a missing map field equal to a null one.
 */
function same(a: unknown, b: unknown): boolean {
  const aNull = a === undefined || a === null;
  const bNull = b === undefined || b === null;
  if (aNull || bNull) return aNull && bNull;
  if (typeof a === "boolean" || typeof a === "number" || typeof a === "string") {
    return typeof a === typeof b && a === b;
  }
  if (Array.isArray(a)) {
    if (!Array.isArray(b) || a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) if (!same(a[i], b[i])) return false;
    return true;
  }
  if (isMap(a) && isMap(b)) {
    for (const k of Object.keys(a)) if (!same(a[k], b[k])) return false;
    for (const k of Object.keys(b)) if (!(k in a) && !same(undefined, b[k])) return false;
    return true;
  }
  return false;
}

/** Code point order; JavaScript's `<` compares UTF-16 code units instead. */
function compareCodePoints(a: string, b: string): number {
  const ca = Array.from(a);
  const cb = Array.from(b);
  const shared = Math.min(ca.length, cb.length);
  for (let i = 0; i < shared; i++) {
    const x = ca[i].codePointAt(0) as number;
    const y = cb[i].codePointAt(0) as number;
    if (x !== y) return x < y ? -1 : 1;
  }
  return ca.length === cb.length ? 0 : ca.length < cb.length ? -1 : 1;
}

/** Key every record of one list, refusing missing and duplicate keys. */
function index(records: readonly DiffRecord[], key: string, side: string): Map<string, number> {
  const byKey = new Map<string, number>();
  records.forEach((record, i) => {
    const k = keyOf(isMap(record) ? record[key] : undefined, key);
    if (k === null) {
      throw new TypeError(`record ${i} in ${side} has no value at "${key}"`);
    }
    // Picking one of two duplicates would report changes that never happened.
    if (byKey.has(k)) {
      throw new RangeError(`duplicate key "${k}" in ${side}`);
    }
    byKey.set(k, i);
  });
  return byKey;
}

/**
 * Compare two lists of records matched by `key`: what was added, what was
 * removed, and what changed and in which fields.
 */
export function diffByKey(
  before: readonly DiffRecord[],
  after: readonly DiffRecord[],
  key: string,
): RecordDiff<DiffRecord> {
  if (!Array.isArray(before) || !Array.isArray(after)) {
    throw new TypeError("diffByKey needs two lists of records");
  }
  if (typeof key !== "string" || key.length === 0) {
    throw new TypeError("diffByKey needs a non-empty key name");
  }

  const beforeKeys = index(before, key, "before");
  const afterKeys = index(after, key, "after");

  const added: DiffRecord[] = [];
  const changed: RecordChange<DiffRecord>[] = [];
  let unchanged = 0;

  for (const [k, i] of afterKeys) {
    const next = after[i];
    const j = beforeKeys.get(k);
    if (j === undefined) {
      added.push(next);
      continue;
    }
    const prev = before[j];
    const names = new Set<string>([...Object.keys(prev), ...Object.keys(next)]);
    const fields = [...names].filter((name) => !same(prev[name], next[name])).sort(compareCodePoints);
    if (fields.length === 0) unchanged++;
    else changed.push({ key: k, before: prev, after: next, fields });
  }

  const removed: DiffRecord[] = [];
  for (const [k, j] of beforeKeys) {
    if (!afterKeys.has(k)) removed.push(before[j]);
  }

  return { added, removed, changed, unchanged };
}