UNPKG

6.86 kBJavaScriptView Raw
1const isObject = value => {
2 const type = typeof value;
3 return value !== null && (type === 'object' || type === 'function');
4};
5
6const isEmptyObject = value => isObject(value) && Object.keys(value).length === 0;
7
8const disallowedKeys = new Set([
9 '__proto__',
10 'prototype',
11 'constructor',
12]);
13
14const digits = new Set('0123456789');
15
16function getPathSegments(path) {
17 const parts = [];
18 let currentSegment = '';
19 let currentPart = 'start';
20 let isIgnoring = false;
21
22 for (const character of path) {
23 switch (character) {
24 case '\\': {
25 if (currentPart === 'index') {
26 throw new Error('Invalid character in an index');
27 }
28
29 if (currentPart === 'indexEnd') {
30 throw new Error('Invalid character after an index');
31 }
32
33 if (isIgnoring) {
34 currentSegment += character;
35 }
36
37 currentPart = 'property';
38 isIgnoring = !isIgnoring;
39 break;
40 }
41
42 case '.': {
43 if (currentPart === 'index') {
44 throw new Error('Invalid character in an index');
45 }
46
47 if (currentPart === 'indexEnd') {
48 currentPart = 'property';
49 break;
50 }
51
52 if (isIgnoring) {
53 isIgnoring = false;
54 currentSegment += character;
55 break;
56 }
57
58 if (disallowedKeys.has(currentSegment)) {
59 return [];
60 }
61
62 parts.push(currentSegment);
63 currentSegment = '';
64 currentPart = 'property';
65 break;
66 }
67
68 case '[': {
69 if (currentPart === 'index') {
70 throw new Error('Invalid character in an index');
71 }
72
73 if (currentPart === 'indexEnd') {
74 currentPart = 'index';
75 break;
76 }
77
78 if (isIgnoring) {
79 isIgnoring = false;
80 currentSegment += character;
81 break;
82 }
83
84 if (currentPart === 'property') {
85 if (disallowedKeys.has(currentSegment)) {
86 return [];
87 }
88
89 parts.push(currentSegment);
90 currentSegment = '';
91 }
92
93 currentPart = 'index';
94 break;
95 }
96
97 case ']': {
98 if (currentPart === 'index') {
99 parts.push(Number.parseInt(currentSegment, 10));
100 currentSegment = '';
101 currentPart = 'indexEnd';
102 break;
103 }
104
105 if (currentPart === 'indexEnd') {
106 throw new Error('Invalid character after an index');
107 }
108
109 // Falls through
110 }
111
112 default: {
113 if (currentPart === 'index' && !digits.has(character)) {
114 throw new Error('Invalid character in an index');
115 }
116
117 if (currentPart === 'indexEnd') {
118 throw new Error('Invalid character after an index');
119 }
120
121 if (currentPart === 'start') {
122 currentPart = 'property';
123 }
124
125 if (isIgnoring) {
126 isIgnoring = false;
127 currentSegment += '\\';
128 }
129
130 currentSegment += character;
131 }
132 }
133 }
134
135 if (isIgnoring) {
136 currentSegment += '\\';
137 }
138
139 switch (currentPart) {
140 case 'property': {
141 if (disallowedKeys.has(currentSegment)) {
142 return [];
143 }
144
145 parts.push(currentSegment);
146
147 break;
148 }
149
150 case 'index': {
151 throw new Error('Index was not closed');
152 }
153
154 case 'start': {
155 parts.push('');
156
157 break;
158 }
159 // No default
160 }
161
162 return parts;
163}
164
165function isStringIndex(object, key) {
166 if (typeof key !== 'number' && Array.isArray(object)) {
167 const index = Number.parseInt(key, 10);
168 return Number.isInteger(index) && object[index] === object[key];
169 }
170
171 return false;
172}
173
174function assertNotStringIndex(object, key) {
175 if (isStringIndex(object, key)) {
176 throw new Error('Cannot use string index');
177 }
178}
179
180export function getProperty(object, path, value) {
181 if (!isObject(object) || typeof path !== 'string') {
182 return value === undefined ? object : value;
183 }
184
185 const pathArray = getPathSegments(path);
186 if (pathArray.length === 0) {
187 return value;
188 }
189
190 for (let index = 0; index < pathArray.length; index++) {
191 const key = pathArray[index];
192
193 if (isStringIndex(object, key)) {
194 object = index === pathArray.length - 1 ? undefined : null;
195 } else {
196 object = object[key];
197 }
198
199 if (object === undefined || object === null) {
200 // `object` is either `undefined` or `null` so we want to stop the loop, and
201 // if this is not the last bit of the path, and
202 // if it didn't return `undefined`
203 // it would return `null` if `object` is `null`
204 // but we want `get({foo: null}, 'foo.bar')` to equal `undefined`, or the supplied value, not `null`
205 if (index !== pathArray.length - 1) {
206 return value;
207 }
208
209 break;
210 }
211 }
212
213 return object === undefined ? value : object;
214}
215
216export function setProperty(object, path, value) {
217 if (!isObject(object) || typeof path !== 'string') {
218 return object;
219 }
220
221 const root = object;
222 const pathArray = getPathSegments(path);
223
224 for (let index = 0; index < pathArray.length; index++) {
225 const key = pathArray[index];
226
227 assertNotStringIndex(object, key);
228
229 if (index === pathArray.length - 1) {
230 object[key] = value;
231 } else if (!isObject(object[key])) {
232 object[key] = typeof pathArray[index + 1] === 'number' ? [] : {};
233 }
234
235 object = object[key];
236 }
237
238 return root;
239}
240
241export function deleteProperty(object, path) {
242 if (!isObject(object) || typeof path !== 'string') {
243 return false;
244 }
245
246 const pathArray = getPathSegments(path);
247
248 for (let index = 0; index < pathArray.length; index++) {
249 const key = pathArray[index];
250
251 assertNotStringIndex(object, key);
252
253 if (index === pathArray.length - 1) {
254 delete object[key];
255 return true;
256 }
257
258 object = object[key];
259
260 if (!isObject(object)) {
261 return false;
262 }
263 }
264}
265
266export function hasProperty(object, path) {
267 if (!isObject(object) || typeof path !== 'string') {
268 return false;
269 }
270
271 const pathArray = getPathSegments(path);
272 if (pathArray.length === 0) {
273 return false;
274 }
275
276 for (const key of pathArray) {
277 if (!isObject(object) || !(key in object) || isStringIndex(object, key)) {
278 return false;
279 }
280
281 object = object[key];
282 }
283
284 return true;
285}
286
287// TODO: Backslashes with no effect should not be escaped
288export function escapePath(path) {
289 if (typeof path !== 'string') {
290 throw new TypeError('Expected a string');
291 }
292
293 return path.replace(/[\\.[]/g, '\\$&');
294}
295
296// The keys returned by Object.entries() for arrays are strings
297function entries(value) {
298 const result = Object.entries(value);
299 if (Array.isArray(value)) {
300 return result.map(([key, value]) => [Number(key), value]);
301 }
302
303 return result;
304}
305
306function stringifyPath(pathSegments) {
307 let result = '';
308
309 for (let [index, segment] of entries(pathSegments)) {
310 if (typeof segment === 'number') {
311 result += `[${segment}]`;
312 } else {
313 segment = escapePath(segment);
314 result += index === 0 ? segment : `.${segment}`;
315 }
316 }
317
318 return result;
319}
320
321function * deepKeysIterator(object, currentPath = []) {
322 if (!isObject(object) || isEmptyObject(object)) {
323 if (currentPath.length > 0) {
324 yield stringifyPath(currentPath);
325 }
326
327 return;
328 }
329
330 for (const [key, value] of entries(object)) {
331 yield * deepKeysIterator(value, [...currentPath, key]);
332 }
333}
334
335export function deepKeys(object) {
336 return [...deepKeysIterator(object)];
337}