338 lines
6.7 KiB
JavaScript
338 lines
6.7 KiB
JavaScript
const isObject = value => {
|
|
const type = typeof value;
|
|
return value !== null && (type === 'object' || type === 'function');
|
|
};
|
|
|
|
const isEmptyObject = value => isObject(value) && Object.keys(value).length === 0;
|
|
|
|
const disallowedKeys = new Set([
|
|
'__proto__',
|
|
'prototype',
|
|
'constructor',
|
|
]);
|
|
|
|
const digits = new Set('0123456789');
|
|
|
|
function getPathSegments(path) {
|
|
const parts = [];
|
|
let currentSegment = '';
|
|
let currentPart = 'start';
|
|
let isIgnoring = false;
|
|
|
|
for (const character of path) {
|
|
switch (character) {
|
|
case '\\': {
|
|
if (currentPart === 'index') {
|
|
throw new Error('Invalid character in an index');
|
|
}
|
|
|
|
if (currentPart === 'indexEnd') {
|
|
throw new Error('Invalid character after an index');
|
|
}
|
|
|
|
if (isIgnoring) {
|
|
currentSegment += character;
|
|
}
|
|
|
|
currentPart = 'property';
|
|
isIgnoring = !isIgnoring;
|
|
break;
|
|
}
|
|
|
|
case '.': {
|
|
if (currentPart === 'index') {
|
|
throw new Error('Invalid character in an index');
|
|
}
|
|
|
|
if (currentPart === 'indexEnd') {
|
|
currentPart = 'property';
|
|
break;
|
|
}
|
|
|
|
if (isIgnoring) {
|
|
isIgnoring = false;
|
|
currentSegment += character;
|
|
break;
|
|
}
|
|
|
|
if (disallowedKeys.has(currentSegment)) {
|
|
return [];
|
|
}
|
|
|
|
parts.push(currentSegment);
|
|
currentSegment = '';
|
|
currentPart = 'property';
|
|
break;
|
|
}
|
|
|
|
case '[': {
|
|
if (currentPart === 'index') {
|
|
throw new Error('Invalid character in an index');
|
|
}
|
|
|
|
if (currentPart === 'indexEnd') {
|
|
currentPart = 'index';
|
|
break;
|
|
}
|
|
|
|
if (isIgnoring) {
|
|
isIgnoring = false;
|
|
currentSegment += character;
|
|
break;
|
|
}
|
|
|
|
if (currentPart === 'property') {
|
|
if (disallowedKeys.has(currentSegment)) {
|
|
return [];
|
|
}
|
|
|
|
parts.push(currentSegment);
|
|
currentSegment = '';
|
|
}
|
|
|
|
currentPart = 'index';
|
|
break;
|
|
}
|
|
|
|
case ']': {
|
|
if (currentPart === 'index') {
|
|
parts.push(Number.parseInt(currentSegment, 10));
|
|
currentSegment = '';
|
|
currentPart = 'indexEnd';
|
|
break;
|
|
}
|
|
|
|
if (currentPart === 'indexEnd') {
|
|
throw new Error('Invalid character after an index');
|
|
}
|
|
|
|
// Falls through
|
|
}
|
|
|
|
default: {
|
|
if (currentPart === 'index' && !digits.has(character)) {
|
|
throw new Error('Invalid character in an index');
|
|
}
|
|
|
|
if (currentPart === 'indexEnd') {
|
|
throw new Error('Invalid character after an index');
|
|
}
|
|
|
|
if (currentPart === 'start') {
|
|
currentPart = 'property';
|
|
}
|
|
|
|
if (isIgnoring) {
|
|
isIgnoring = false;
|
|
currentSegment += '\\';
|
|
}
|
|
|
|
currentSegment += character;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isIgnoring) {
|
|
currentSegment += '\\';
|
|
}
|
|
|
|
switch (currentPart) {
|
|
case 'property': {
|
|
if (disallowedKeys.has(currentSegment)) {
|
|
return [];
|
|
}
|
|
|
|
parts.push(currentSegment);
|
|
|
|
break;
|
|
}
|
|
|
|
case 'index': {
|
|
throw new Error('Index was not closed');
|
|
}
|
|
|
|
case 'start': {
|
|
parts.push('');
|
|
|
|
break;
|
|
}
|
|
// No default
|
|
}
|
|
|
|
return parts;
|
|
}
|
|
|
|
function isStringIndex(object, key) {
|
|
if (typeof key !== 'number' && Array.isArray(object)) {
|
|
const index = Number.parseInt(key, 10);
|
|
return Number.isInteger(index) && object[index] === object[key];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function assertNotStringIndex(object, key) {
|
|
if (isStringIndex(object, key)) {
|
|
throw new Error('Cannot use string index');
|
|
}
|
|
}
|
|
|
|
export function getProperty(object, path, value) {
|
|
if (!isObject(object) || typeof path !== 'string') {
|
|
return value === undefined ? object : value;
|
|
}
|
|
|
|
const pathArray = getPathSegments(path);
|
|
if (pathArray.length === 0) {
|
|
return value;
|
|
}
|
|
|
|
for (let index = 0; index < pathArray.length; index++) {
|
|
const key = pathArray[index];
|
|
|
|
if (isStringIndex(object, key)) {
|
|
object = index === pathArray.length - 1 ? undefined : null;
|
|
} else {
|
|
object = object[key];
|
|
}
|
|
|
|
if (object === undefined || object === null) {
|
|
// `object` is either `undefined` or `null` so we want to stop the loop, and
|
|
// if this is not the last bit of the path, and
|
|
// if it didn't return `undefined`
|
|
// it would return `null` if `object` is `null`
|
|
// but we want `get({foo: null}, 'foo.bar')` to equal `undefined`, or the supplied value, not `null`
|
|
if (index !== pathArray.length - 1) {
|
|
return value;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return object === undefined ? value : object;
|
|
}
|
|
|
|
export function setProperty(object, path, value) {
|
|
if (!isObject(object) || typeof path !== 'string') {
|
|
return object;
|
|
}
|
|
|
|
const root = object;
|
|
const pathArray = getPathSegments(path);
|
|
|
|
for (let index = 0; index < pathArray.length; index++) {
|
|
const key = pathArray[index];
|
|
|
|
assertNotStringIndex(object, key);
|
|
|
|
if (index === pathArray.length - 1) {
|
|
object[key] = value;
|
|
} else if (!isObject(object[key])) {
|
|
object[key] = typeof pathArray[index + 1] === 'number' ? [] : {};
|
|
}
|
|
|
|
object = object[key];
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
export function deleteProperty(object, path) {
|
|
if (!isObject(object) || typeof path !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const pathArray = getPathSegments(path);
|
|
|
|
for (let index = 0; index < pathArray.length; index++) {
|
|
const key = pathArray[index];
|
|
|
|
assertNotStringIndex(object, key);
|
|
|
|
if (index === pathArray.length - 1) {
|
|
delete object[key];
|
|
return true;
|
|
}
|
|
|
|
object = object[key];
|
|
|
|
if (!isObject(object)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hasProperty(object, path) {
|
|
if (!isObject(object) || typeof path !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const pathArray = getPathSegments(path);
|
|
if (pathArray.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
for (const key of pathArray) {
|
|
if (!isObject(object) || !(key in object) || isStringIndex(object, key)) {
|
|
return false;
|
|
}
|
|
|
|
object = object[key];
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// TODO: Backslashes with no effect should not be escaped
|
|
export function escapePath(path) {
|
|
if (typeof path !== 'string') {
|
|
throw new TypeError('Expected a string');
|
|
}
|
|
|
|
return path.replaceAll(/[\\.[]/g, '\\$&');
|
|
}
|
|
|
|
// The keys returned by Object.entries() for arrays are strings
|
|
function entries(value) {
|
|
const result = Object.entries(value);
|
|
if (Array.isArray(value)) {
|
|
return result.map(([key, value]) => [Number(key), value]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function stringifyPath(pathSegments) {
|
|
let result = '';
|
|
|
|
for (let [index, segment] of entries(pathSegments)) {
|
|
if (typeof segment === 'number') {
|
|
result += `[${segment}]`;
|
|
} else {
|
|
segment = escapePath(segment);
|
|
result += index === 0 ? segment : `.${segment}`;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function * deepKeysIterator(object, currentPath = []) {
|
|
if (!isObject(object) || isEmptyObject(object)) {
|
|
if (currentPath.length > 0) {
|
|
yield stringifyPath(currentPath);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
for (const [key, value] of entries(object)) {
|
|
yield * deepKeysIterator(value, [...currentPath, key]);
|
|
}
|
|
}
|
|
|
|
export function deepKeys(object) {
|
|
return [...deepKeysIterator(object)];
|
|
}
|