File size: 5,143 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// src/query.ts
//
// A small, zero-dependency "jq / MongoDB-ish" query engine over JSON documents:
//   - path get/set/projection  (dot + bracket index: "a.b[0].c")
//   - filter predicates        (Mongo-like: { age: { $gt: 18 }, $or: [...] })
//   - sort / skip / limit

export type Doc = unknown;

type Path = string | readonly (string | number)[];

function tokenizePath(path: Path): (string | number)[] {
  if (Array.isArray(path)) return [...path];
  const tokens: (string | number)[] = [];
  for (const seg of String(path).split('.')) {
    let s = seg;
    while (s.length) {
      const m = s.match(/^([^[]*)\[(\d+)\](.*)$/);
      if (m) {
        if (m[1]) tokens.push(m[1]);
        tokens.push(Number(m[2]));
        s = m[3]!;
      } else {
        tokens.push(s);
        s = '';
      }
    }
  }
  return tokens;
}

export function getPath(doc: Doc, path: Path): unknown {
  let cur: unknown = doc;
  for (const t of tokenizePath(path)) {
    if (cur === null || cur === undefined) return undefined;
    cur = (cur as Record<string | number, unknown>)[t];
  }
  return cur;
}

export function setPath(obj: Doc, path: Path, value: unknown): Doc {
  const tokens = tokenizePath(path);
  let cur = obj as Record<string | number, unknown>;
  for (let i = 0; i < tokens.length - 1; i++) {
    const t = tokens[i]!;
    if (cur[t] === null || cur[t] === undefined || typeof cur[t] !== 'object') {
      cur[t] = typeof tokens[i + 1] === 'number' ? [] : {};
    }
    cur = cur[t] as Record<string | number, unknown>;
  }
  cur[tokens[tokens.length - 1]!] = value;
  return obj;
}

/** Keep only the given paths (inclusion). Returns a new object. */
export function project(doc: Doc, paths?: readonly string[]): Doc {
  if (!paths || !paths.length) return doc;
  const out: Record<string, unknown> = {};
  for (const p of paths) {
    const v = getPath(doc, p);
    if (v !== undefined) setPath(out, p, v);
  }
  return out;
}

// --- filter --------------------------------------------------------------

type Cond = unknown;

function matchCond(val: unknown, cond: Cond): boolean {
  if (cond === null || typeof cond !== 'object' || cond instanceof RegExp) {
    if (cond instanceof RegExp) {
      // A caller-supplied RegExp with the global/sticky flag is stateful:
      // .test() advances lastIndex. Reset it so every document is tested from
      // the start instead of alternating match/miss across documents.
      cond.lastIndex = 0;
      return typeof val === 'string' && cond.test(val);
    }
    return val === cond;
  }
  for (const op of Object.keys(cond as Record<string, unknown>)) {
    const arg = (cond as Record<string, unknown>)[op];
    switch (op) {
      case '$eq':
        if (val !== arg) return false;
        break;
      case '$ne':
        if (val === arg) return false;
        break;
      case '$gt':
        if (!((val as number) > (arg as number))) return false;
        break;
      case '$gte':
        if (!((val as number) >= (arg as number))) return false;
        break;
      case '$lt':
        if (!((val as number) < (arg as number))) return false;
        break;
      case '$lte':
        if (!((val as number) <= (arg as number))) return false;
        break;
      case '$in':
        if (!Array.isArray(arg) || !arg.includes(val)) return false;
        break;
      case '$nin':
        if (!Array.isArray(arg) || arg.includes(val)) return false;
        break;
      case '$regex': {
        const re =
          arg instanceof RegExp ? arg : Array.isArray(arg) ? new RegExp(arg[0] as string, arg[1] as string | undefined) : new RegExp(arg as string);
        if (typeof val !== 'string') return false;
        // Reset a stateful (global/sticky) RegExp so a reused instance does not
        // carry lastIndex over from the previous document.
        re.lastIndex = 0;
        if (!re.test(val)) return false;
        break;
      }
      case '$exists':
        if ((val !== undefined) !== !!arg) return false;
        break;
      case '$contains':
        if (!Array.isArray(val) || !val.includes(arg)) return false;
        break;
      case '$type':
        if (typeof val !== arg) return false;
        break;
      default:
        return false;
    }
  }
  return true;
}

/** Does `doc` satisfy the Mongo-like `filter`? */
export function match(doc: Doc, filter?: Record<string, unknown> | null): boolean {
  if (!filter || Object.keys(filter).length === 0) return true;
  for (const key of Object.keys(filter)) {
    const cond = filter[key];
    if (key === '$and') {
      if (!Array.isArray(cond) || !cond.every((f) => match(doc, f as Record<string, unknown>))) return false;
    } else if (key === '$or') {
      if (!Array.isArray(cond) || !cond.some((f) => match(doc, f as Record<string, unknown>))) return false;
    } else if (key === '$nor') {
      if (!Array.isArray(cond) || cond.some((f) => match(doc, f as Record<string, unknown>))) return false;
    } else if (key === '$not') {
      if (match(doc, cond as Record<string, unknown>)) return false;
    } else {
      if (!matchCond(getPath(doc, key), cond)) return false;
    }
  }
  return true;
}