dot-prop
Version:
Get, set, or delete a property from a nested object using a dot path
552 lines (442 loc) • 14.6 kB
JavaScript
const isObject = value => {
const type = typeof value;
return value !== null && (type === 'object' || type === 'function');
};
const disallowedKeys = new Set([
'__proto__',
'prototype',
'constructor',
]);
// Longest disallowed key. A longer segment can't be disallowed, so the membership
// check is skipped for it. This keeps `parsePath` linear on inputs like `a[][]…`.
const maxDisallowedKeyLength = Math.max(...[...disallowedKeys].map(key => key.length));
// Maximum allowed array index to prevent DoS via memory exhaustion.
const MAX_ARRAY_INDEX = 1_000_000;
// Optimized digit check without Set overhead.
const isDigit = character => character >= '0' && character <= '9';
// Check if a segment should be coerced to a number.
function shouldCoerceToNumber(segment) {
// Only coerce valid non-negative integers without leading zeros.
if (segment === '0') {
return true;
}
if (/^[1-9]\d*$/.test(segment)) {
const parsedNumber = Number.parseInt(segment, 10);
// Check within safe integer range and under MAX_ARRAY_INDEX to prevent DoS.
return parsedNumber <= Number.MAX_SAFE_INTEGER && parsedNumber <= MAX_ARRAY_INDEX;
}
return false;
}
// Helper to process a path segment (eliminates duplication).
function processSegment(segment, parts) {
if (disallowedKeys.has(segment)) {
return false; // Signal to return empty array.
}
if (segment && shouldCoerceToNumber(segment)) {
parts.push(Number.parseInt(segment, 10));
} else {
parts.push(segment);
}
return true;
}
export function parsePath(path) { // eslint-disable-line complexity
if (typeof path !== 'string') {
throw new TypeError(`Expected a string, got ${typeof path}`);
}
const parts = [];
let currentSegment = '';
let indexSegment = '';
let hadProperty = false;
let currentPart = 'start';
let isEscaping = false;
let position = 0;
for (const character of path) {
position++;
// Handle escaping.
if (isEscaping) {
currentSegment += character;
isEscaping = false;
continue;
}
// Handle escape character.
if (character === '\\') {
if (currentPart === 'index') {
throw new Error(`Invalid character '${character}' in an index at position ${position}`);
}
if (currentPart === 'indexEnd') {
throw new Error(`Invalid character '${character}' after an index at position ${position}`);
}
isEscaping = true;
currentPart = currentPart === 'start' ? 'property' : currentPart;
continue;
}
switch (character) {
case '.': {
if (currentPart === 'index') {
throw new Error(`Invalid character '${character}' in an index at position ${position}`);
}
if (currentPart === 'indexEnd') {
currentPart = 'property';
break;
}
if (!processSegment(currentSegment, parts)) {
return [];
}
currentSegment = '';
currentPart = 'property';
break;
}
case '[': {
if (currentPart === 'index') {
throw new Error(`Invalid character '${character}' in an index at position ${position}`);
}
if (currentPart === 'indexEnd') {
currentPart = 'index';
break;
}
// Entering an index from a property. Keep the property in `currentSegment`
// and collect index digits separately, so empty brackets (`[]`) don't
// require pushing then popping the segment (which re-scans a growing
// literal on every `[` and makes `a[][]…` quadratic).
//
// Reject a disallowed key here, before the index opens, so `__proto__[0]`
// etc. still return []. The length guard keeps this O(1) for long literals.
if (
currentPart === 'property'
&& currentSegment.length <= maxDisallowedKeyLength
&& disallowedKeys.has(currentSegment)
) {
return [];
}
hadProperty = currentPart === 'property';
currentPart = 'index';
break;
}
case ']': {
if (currentPart === 'indexEnd') {
throw new Error(`Invalid character '${character}' after an index at position ${position}`);
}
if (currentPart !== 'index') {
// In property context, treat ] as a literal character. Transition out
// of 'start' like any other literal, otherwise a leading ']' leaves the
// segment in 'start' and is discarded at the end of the path.
if (currentPart === 'start') {
currentPart = 'property';
}
currentSegment += character;
break;
}
if (indexSegment === '') {
// Empty brackets - treat `[]` as literal characters of the property.
currentSegment += '[]';
currentPart = 'property';
break;
}
// A real index closes. Flush the pending property segment first
// (an empty one too, for paths like `foo.[0]`).
if ((currentSegment !== '' || hadProperty) && !processSegment(currentSegment, parts)) {
return [];
}
currentSegment = '';
hadProperty = false;
// Index must be digits only (enforced by default case).
// The String() check rejects leading zeros like `[01]`.
const parsedNumber = Number.parseInt(indexSegment, 10);
const isValidInteger = parsedNumber <= MAX_ARRAY_INDEX
&& indexSegment === String(parsedNumber);
// Keep as string if not a valid integer representation or exceeds MAX_ARRAY_INDEX.
parts.push(isValidInteger ? parsedNumber : indexSegment);
indexSegment = '';
currentPart = 'indexEnd';
break;
}
default: {
if (currentPart === 'index') {
if (!isDigit(character)) {
throw new Error(`Invalid character '${character}' in an index at position ${position}`);
}
indexSegment += character;
break;
}
if (currentPart === 'indexEnd') {
throw new Error(`Invalid character '${character}' after an index at position ${position}`);
}
if (currentPart === 'start') {
currentPart = 'property';
}
currentSegment += character;
}
}
}
// Handle unfinished escaping (trailing backslash)
if (isEscaping) {
currentSegment += '\\';
}
// Handle end of path
switch (currentPart) {
case 'property': {
if (!processSegment(currentSegment, parts)) {
return [];
}
break;
}
case 'index': {
throw new Error('Index was not closed');
}
case 'start': {
parts.push('');
break;
}
// No default
}
return parts;
}
function normalizePath(path) {
if (typeof path === 'string') {
return parsePath(path);
}
if (Array.isArray(path)) {
const normalized = [];
for (const [index, segment] of path.entries()) {
// Type validation.
if (typeof segment !== 'string' && typeof segment !== 'number') {
throw new TypeError(`Expected a string or number for path segment at index ${index}, got ${typeof segment}`);
}
// Validate numbers are finite (reject NaN, Infinity, -Infinity).
if (typeof segment === 'number' && !Number.isFinite(segment)) {
throw new TypeError(`Path segment at index ${index} must be a finite number, got ${segment}`);
}
// Check for disallowed keys.
if (disallowedKeys.has(segment)) {
return [];
}
// Normalize numeric strings to numbers for simplicity.
// This treats ['items', '0'] the same as ['items', 0].
if (typeof segment === 'string' && shouldCoerceToNumber(segment)) {
normalized.push(Number.parseInt(segment, 10));
} else {
normalized.push(segment);
}
}
return normalized;
}
return [];
}
export function getProperty(object, path, value) {
if (!isObject(object) || (typeof path !== 'string' && !Array.isArray(path))) {
return value === undefined ? object : value;
}
const pathArray = normalizePath(path);
if (pathArray.length === 0) {
return value;
}
for (let index = 0; index < pathArray.length; index++) {
const key = pathArray[index];
object = object[key];
if (object === undefined || object === null) {
// Return default value if we hit undefined/null before the end of the path.
// This ensures get({foo: null}, 'foo.bar') returns the default 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' && !Array.isArray(path))) {
return object;
}
const root = object;
const pathArray = normalizePath(path);
if (pathArray.length === 0) {
return object;
}
for (let index = 0; index < pathArray.length; index++) {
const key = pathArray[index];
if (index === pathArray.length - 1) {
object[key] = value;
continue;
}
const existingValue = object[key];
if (isObject(existingValue)) {
object = existingValue;
continue;
}
// Create a fresh container when the existing value is missing or primitive.
const nextKey = pathArray[index + 1];
const shouldCreateArray = typeof nextKey === 'number';
object[key] = shouldCreateArray ? [] : {};
object = object[key];
}
return root;
}
export function deleteProperty(object, path) {
if (!isObject(object) || (typeof path !== 'string' && !Array.isArray(path))) {
return false;
}
const pathArray = normalizePath(path);
if (pathArray.length === 0) {
return false;
}
for (let index = 0; index < pathArray.length; index++) {
const key = pathArray[index];
if (index === pathArray.length - 1) {
const existed = Object.hasOwn(object, key);
if (!existed) {
return false;
}
delete object[key];
return true;
}
const existingValue = object[key];
if (!isObject(existingValue)) {
return false;
}
object = existingValue;
}
}
export function hasProperty(object, path) {
if (!isObject(object) || (typeof path !== 'string' && !Array.isArray(path))) {
return false;
}
const pathArray = normalizePath(path);
if (pathArray.length === 0) {
return false;
}
for (const key of pathArray) {
if (!isObject(object) || !(key in object)) {
return false;
}
object = object[key];
}
return true;
}
export function escapePath(path) {
if (typeof path !== 'string') {
throw new TypeError(`Expected a string, got ${typeof path}`);
}
// Escape special characters in one pass
return path.replaceAll(/[\\.[]/g, String.raw`\$&`);
}
function normalizeEntries(value) {
const entries = Object.entries(value);
if (Array.isArray(value)) {
return entries.map(([key, entryValue]) => {
// Use shouldCoerceToNumber for consistency with parsePath
const normalizedKey = shouldCoerceToNumber(key)
? Number.parseInt(key, 10)
: key;
return [normalizedKey, entryValue];
});
}
return entries;
}
// Format a single path segment's contribution to a stringified path.
// `isFirst` is whether the segment starts the path (no leading separator).
// `startsOutput` is whether nothing has been emitted yet, which happens when every
// preceding segment was an empty string key.
function formatPathSegment(segment, isFirst, startsOutput, preferDotForIndices) {
// An index may only use bracket notation when it is genuinely at the start of
// the path or something precedes it in the output. A `[0]` that merely *looks*
// like the start parses back as a bare index, dropping the empty keys before it.
const useDotForIndex = !isFirst && (preferDotForIndices || startsOutput);
if (typeof segment === 'number') {
// Non-integer or negative numbers are treated as string keys.
if (!Number.isInteger(segment) || segment < 0) {
const escaped = escapePath(String(segment));
return isFirst ? escaped : `.${escaped}`;
}
return useDotForIndex ? `.${segment}` : `[${segment}]`;
}
// Empty string keys carry no text, only a separator when not first.
if (segment === '') {
return isFirst ? '' : '.';
}
// Numeric strings are normalized to numbers.
if (shouldCoerceToNumber(segment)) {
const numericValue = Number.parseInt(segment, 10);
return useDotForIndex ? `.${numericValue}` : `[${numericValue}]`;
}
// Regular strings use dot notation.
const escaped = escapePath(segment);
return isFirst ? escaped : `.${escaped}`;
}
export function stringifyPath(pathSegments, options = {}) {
if (!Array.isArray(pathSegments)) {
throw new TypeError(`Expected an array, got ${typeof pathSegments}`);
}
const {preferDotForIndices = false} = options;
const parts = [];
let startsOutput = true;
for (const [index, segment] of pathSegments.entries()) {
// Validate segment types at runtime
if (typeof segment !== 'string' && typeof segment !== 'number') {
throw new TypeError(`Expected a string or number for path segment at index ${index}, got ${typeof segment}`);
}
const part = formatPathSegment(segment, index === 0, startsOutput, preferDotForIndices);
parts.push(part);
if (part !== '') {
startsOutput = false;
}
}
return parts.join('');
}
function * deepKeysIterator(object) {
if (!isObject(object)) {
return;
}
const rootEntries = normalizeEntries(object);
if (rootEntries.length === 0) {
return;
}
// Iterative depth-first walk with an explicit stack, so deeply nested input
// cannot overflow the call stack (unbounded recursion is a DoS vector).
//
// The path string is built incrementally: each segment is escaped and appended
// once, when we descend into it. Re-stringifying the whole path for every leaf
// (which re-escapes every ancestor segment) makes the walk quadratic in the
// depth, so a deeply nested object cheaply blocks the event loop for seconds
// (an algorithmic-complexity DoS vector).
const ancestors = new Set([object]);
const stack = [{iterator: rootEntries[Symbol.iterator](), prefix: '', object}];
while (stack.length > 0) {
const frame = stack.at(-1);
const {value: entry, done} = frame.iterator.next();
if (done) {
ancestors.delete(stack.pop().object);
continue;
}
const [key, value] = entry;
const {prefix} = frame;
const path = prefix + formatPathSegment(key, stack.length === 1, prefix === '', false);
if (isObject(value)) {
// Skip circular references (object already on the current path).
if (ancestors.has(value)) {
continue;
}
// The entries decide both whether this is a leaf and what to walk, so they are computed once.
const entries = normalizeEntries(value);
if (entries.length > 0) {
ancestors.add(value);
stack.push({iterator: entries[Symbol.iterator](), prefix: path, object: value});
continue;
}
}
yield path;
}
}
export function deepKeys(object) {
return [...deepKeysIterator(object)];
}
export function unflatten(object) {
const result = {};
if (!isObject(object)) {
return result;
}
for (const [path, value] of Object.entries(object)) {
setProperty(result, path, value);
}
return result;
}