|
| 1 | +/** |
| 2 | + * PROTOTYPE — throwaway code. |
| 3 | + * |
| 4 | + * "What data are available": walks a live object into a compact type |
| 5 | + * SKELETON (keys and type names, no values) so users can see the shape of a |
| 6 | + * source while composing queries — independent of any query. |
| 7 | + * |
| 8 | + * - primitives -> their type name ('string', 'number', ...) |
| 9 | + * - functions -> 'function' |
| 10 | + * - arrays -> [skeleton of first item, '+N more'] |
| 11 | + * - Map/Set (incl. -like)-> 'Map(size) { <key> => <value> }' expansions |
| 12 | + * - class instances -> own props + $class tag |
| 13 | + * - circular -> '[circular]' |
| 14 | + * - depth/prop caps -> '...' |
| 15 | + */ |
| 16 | +import type { NormalizeOptions } from './normalize' |
| 17 | +import { isIgnoredKey } from './normalize' |
| 18 | + |
| 19 | +export type SkeletonOptions = Pick< |
| 20 | + NormalizeOptions, |
| 21 | + 'maxDepth' | 'maxProps' | 'ignoreFunctions' | 'ignoreUnderscorePrefixed' | 'ignoreDollarPrefixed' |
| 22 | +> |
| 23 | + |
| 24 | +interface SkeletonWalker { |
| 25 | + seen: WeakSet<object> |
| 26 | + nodes: number |
| 27 | + opts: Required<SkeletonOptions> |
| 28 | +} |
| 29 | + |
| 30 | +export function skeletonOf(value: unknown, options: SkeletonOptions = {}): { skeleton: unknown, nodes: number, ms: number } { |
| 31 | + const started = performance.now() |
| 32 | + const walker: SkeletonWalker = { |
| 33 | + seen: new WeakSet(), |
| 34 | + nodes: 0, |
| 35 | + opts: { |
| 36 | + maxDepth: options.maxDepth ?? 5, |
| 37 | + maxProps: options.maxProps ?? 80, |
| 38 | + ignoreFunctions: options.ignoreFunctions ?? false, |
| 39 | + ignoreUnderscorePrefixed: options.ignoreUnderscorePrefixed ?? false, |
| 40 | + ignoreDollarPrefixed: options.ignoreDollarPrefixed ?? false, |
| 41 | + }, |
| 42 | + } |
| 43 | + const skeleton = walk(value, walker, 0) |
| 44 | + return { skeleton, nodes: walker.nodes, ms: Math.round((performance.now() - started) * 100) / 100 } |
| 45 | +} |
| 46 | + |
| 47 | +function isMapLike(v: object): v is Map<unknown, unknown> { |
| 48 | + return typeof (v as Map<unknown, unknown>).entries === 'function' |
| 49 | + && typeof (v as Map<unknown, unknown>).get === 'function' |
| 50 | + && typeof (v as Map<unknown, unknown>).size === 'number' |
| 51 | +} |
| 52 | + |
| 53 | +function isSetLike(v: object): v is Set<unknown> { |
| 54 | + return typeof (v as Set<unknown>).has === 'function' |
| 55 | + && typeof (v as Set<unknown>)[Symbol.iterator] === 'function' |
| 56 | + && typeof (v as Map<unknown, unknown>).get !== 'function' |
| 57 | + && typeof (v as Set<unknown>).size === 'number' |
| 58 | +} |
| 59 | + |
| 60 | +function walk(value: unknown, w: SkeletonWalker, depth: number): unknown { |
| 61 | + w.nodes++ |
| 62 | + if (value === null || value === undefined) |
| 63 | + return String(value) |
| 64 | + const t = typeof value |
| 65 | + if (t !== 'object') |
| 66 | + return t // 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function' |
| 67 | + |
| 68 | + const obj = value as object |
| 69 | + if (obj instanceof Date) |
| 70 | + return 'Date' |
| 71 | + if (obj instanceof RegExp) |
| 72 | + return 'RegExp' |
| 73 | + if (obj instanceof URL) |
| 74 | + return 'URL' |
| 75 | + if (obj instanceof Error) |
| 76 | + return 'Error' |
| 77 | + if (obj instanceof Promise) |
| 78 | + return 'Promise' |
| 79 | + |
| 80 | + if (w.seen.has(obj)) |
| 81 | + return '[circular]' |
| 82 | + if (depth >= w.opts.maxDepth) |
| 83 | + return '...' |
| 84 | + // Ancestor-path tracking (add/delete) so SHARED refs still expand and only |
| 85 | + // true cycles collapse to '[circular]'. |
| 86 | + w.seen.add(obj) |
| 87 | + try { |
| 88 | + return walkObject(obj, w, depth) |
| 89 | + } |
| 90 | + finally { |
| 91 | + w.seen.delete(obj) |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +function walkObject(obj: object, w: SkeletonWalker, depth: number): unknown { |
| 96 | + if (Array.isArray(obj)) { |
| 97 | + const items = w.opts.ignoreFunctions ? obj.filter(item => typeof item !== 'function') : obj |
| 98 | + if (items.length === 0) |
| 99 | + return [] |
| 100 | + const first = walk(items[0], w, depth + 1) |
| 101 | + return items.length > 1 ? [first, `+${items.length - 1} more`] : [first] |
| 102 | + } |
| 103 | + |
| 104 | + if (ArrayBuffer.isView(obj)) |
| 105 | + return obj.constructor?.name ?? 'TypedArray' |
| 106 | + |
| 107 | + if (isMapLike(obj)) { |
| 108 | + const first = obj.entries().next().value as [unknown, unknown] | undefined |
| 109 | + if (!first) |
| 110 | + return `Map(0)` |
| 111 | + return { |
| 112 | + [`Map(${obj.size})`]: { |
| 113 | + key: walk(first[0], w, depth + 1), |
| 114 | + value: walk(first[1], w, depth + 1), |
| 115 | + }, |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + if (isSetLike(obj)) { |
| 120 | + const first = obj[Symbol.iterator]().next().value |
| 121 | + return first === undefined |
| 122 | + ? `Set(0)` |
| 123 | + : { [`Set(${obj.size})`]: walk(first, w, depth + 1) } |
| 124 | + } |
| 125 | + |
| 126 | + const proto = Object.getPrototypeOf(obj) |
| 127 | + const className = proto && proto !== Object.prototype ? (proto.constructor?.name as string | undefined) : undefined |
| 128 | + |
| 129 | + const out: Record<string, unknown> = {} |
| 130 | + if (className && className !== 'Object') |
| 131 | + out.$class = className |
| 132 | + |
| 133 | + const keys = Object.keys(obj).filter(key => !isIgnoredKey(key, w.opts)) |
| 134 | + const cap = Math.min(keys.length, w.opts.maxProps) |
| 135 | + for (let i = 0; i < cap; i++) { |
| 136 | + const key = keys[i] |
| 137 | + let v: unknown |
| 138 | + try { |
| 139 | + v = (obj as Record<string, unknown>)[key] |
| 140 | + } |
| 141 | + catch { |
| 142 | + out[key] = 'getter-error' |
| 143 | + continue |
| 144 | + } |
| 145 | + if (w.opts.ignoreFunctions && typeof v === 'function') |
| 146 | + continue |
| 147 | + out[key] = walk(v, w, depth + 1) |
| 148 | + } |
| 149 | + if (keys.length > cap) |
| 150 | + out['...'] = `+${keys.length - cap} more props` |
| 151 | + return out |
| 152 | +} |
0 commit comments