dot-prop-extra
Version:
Get, set, or delete a property from a nested object using a dot path
681 lines (552 loc) • 17.3 kB
JavaScript
const isObject = value => {
const type = typeof value;
return value !== null && (type === 'object' || type === 'function');
};
// Optimized empty check without creating an array.
const isEmptyObject = value => {
if (!isObject(value)) {
return false;
}
for (const key in value) {
if (Object.hasOwn(value, key)) {
return false;
}
}
return true;
};
const disallowedKeys = new Set([
'__proto__',
'prototype',
'constructor',
]);
// 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;
}
// Helper to process a filter segment like 'bar="baz"'.
function processFilterSegment(segment, parts) {
const equalIndex = segment.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid filter syntax: missing '=' in filter '${segment}'`);
}
const key = segment.slice(0, equalIndex);
const valueString = segment.slice(equalIndex + 1);
if (!key) {
throw new Error(`Invalid filter syntax: empty key in filter '${segment}'`);
}
let value;
try {
value = JSON.parse(valueString);
} catch {
throw new Error(`Invalid filter syntax: could not parse value in filter '${segment}'`);
}
// Only allow primitive values (not objects or arrays)
if (value !== null && typeof value === 'object') {
throw new Error(`Invalid filter syntax: filter value must be a JSON primitive, got ${typeof value}`);
}
parts.push({key, value});
return true;
}
// Helper to find an item in an array that matches a filter
function findFilteredItem(array, filter) {
if (!Array.isArray(array)) {
return undefined;
}
return array.find(item => isObject(item) && item[filter.key] === filter.value);
}
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 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 === 'filter') {
// In filter mode, dots are allowed (e.g., in numeric values)
currentSegment += character;
break;
}
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 === 'filter') {
// In filter mode, accumulate - will be caught by JSON.parse or primitive check
currentSegment += character;
break;
}
if (currentPart === 'indexEnd') {
currentPart = 'index';
break;
}
if (currentPart === 'property' || currentPart === 'start') {
// Only push if we have content OR if we're in 'property' mode (not 'start')
if ((currentSegment || currentPart === 'property') && !processSegment(currentSegment, parts)) {
return [];
}
currentSegment = '';
}
currentPart = 'index';
break;
}
case '=': {
if (currentPart === 'index') {
// Transition to filter mode
currentSegment += character;
currentPart = 'filter';
break;
}
// In other contexts, = is just a regular character
currentSegment += character;
break;
}
case ']': {
if (currentPart === 'index') {
if (currentSegment === '') {
// Empty brackets - backtrack and treat as literal
const lastSegment = parts.pop() || '';
currentSegment = lastSegment + '[]';
currentPart = 'property';
} else {
// Check if it's a valid integer index
const parsedNumber = Number.parseInt(currentSegment, 10);
const isValidInteger = !Number.isNaN(parsedNumber)
&& Number.isFinite(parsedNumber)
&& parsedNumber >= 0
&& parsedNumber <= Number.MAX_SAFE_INTEGER
&& parsedNumber <= MAX_ARRAY_INDEX
&& currentSegment === String(parsedNumber);
if (isValidInteger) {
parts.push(parsedNumber);
} else {
// Not a valid integer - report the first invalid character
// This maintains backward compatibility with old error messages
for (let i = 0; i < currentSegment.length; i++) {
if (!isDigit(currentSegment[i])) {
const errorPosition = position - currentSegment.length + i;
throw new Error(`Invalid character '${currentSegment[i]}' in an index at position ${errorPosition}`);
}
}
// All digits but not valid (e.g., exceeds MAX_ARRAY_INDEX or has leading zeros)
// Keep as string
parts.push(currentSegment);
}
currentSegment = '';
currentPart = 'indexEnd';
}
break;
}
if (currentPart === 'filter') {
if (!processFilterSegment(currentSegment, parts)) {
return [];
}
currentSegment = '';
currentPart = 'indexEnd';
break;
}
if (currentPart === 'indexEnd') {
throw new Error(`Invalid character '${character}' after an index at position ${position}`);
}
// In property context, treat ] as literal character
currentSegment += character;
break;
}
default: {
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 'filter': {
throw new Error('Filter 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()) {
// Handle filter objects in array paths
if (typeof segment === 'object' && segment !== null) {
// Check if it looks like a filter object
const hasKey = 'key' in segment;
const hasValue = 'value' in segment;
if (!hasKey && !hasValue) {
// Empty object or object without filter properties
throw new TypeError(`Expected a string or number for path segment at index ${index}, got ${typeof segment}`);
}
// Validate filter object structure
if (!hasKey || !hasValue) {
throw new TypeError(`Filter object at index ${index} must have 'key' and 'value' properties`);
}
// Validate filter key is a string
if (typeof segment.key !== 'string') {
throw new TypeError(`Filter key at index ${index} must be a string, got ${typeof segment.key}`);
}
// Validate filter value is a primitive (not object or array)
if (segment.value !== null && typeof segment.value === 'object') {
throw new TypeError(`Filter value at index ${index} must be a JSON primitive, got ${typeof segment.value}`);
}
normalized.push(segment);
continue;
}
// 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];
// Handle filter objects
if (typeof key === 'object' && key !== null) {
const found = findFilteredItem(object, key);
if (!found) {
return value;
}
object = found;
} else {
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];
// Handle filter objects
if (typeof key === 'object' && key !== null) {
const found = findFilteredItem(object, key);
if (!found) {
return root;
}
object = found;
} else if (index === pathArray.length - 1) {
object[key] = value;
} else if (isObject(object[key])) {
object = object[key];
} else {
const nextKey = pathArray[index + 1];
// If next key is a filter, check if we would need to create an array that won't have matches
if (typeof nextKey === 'object' && nextKey !== null) {
// Don't create an empty array for a filter that won't match
return root;
}
// Create arrays for numeric indices, objects for string keys
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];
// Handle filter objects
if (typeof key === 'object' && key !== null) {
const found = findFilteredItem(object, key);
if (!found) {
return false;
}
object = found;
} else if (index === pathArray.length - 1) {
const existed = Object.hasOwn(object, key);
if (!existed) {
return false;
}
delete object[key];
return true;
} else {
object = object[key];
if (!isObject(object)) {
return false;
}
}
}
}
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) {
// Handle filter objects
if (typeof key === 'object' && key !== null) {
const found = findFilteredItem(object, key);
if (!found) {
return false;
}
object = found;
} else {
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;
}
export function stringifyPath(pathSegments, options = {}) {
if (!Array.isArray(pathSegments)) {
throw new TypeError(`Expected an array, got ${typeof pathSegments}`);
}
const {preferDotForIndices = false} = options;
const parts = [];
for (const [index, segment] of pathSegments.entries()) {
// Handle filter objects
if (typeof segment === 'object' && segment !== null) {
// Check if it looks like a filter object
const hasKey = 'key' in segment;
const hasValue = 'value' in segment;
if (!hasKey && !hasValue) {
// Empty object or object without filter properties
throw new TypeError(`Expected a string or number for path segment at index ${index}, got ${typeof segment}`);
}
// Validate filter object structure
if (!hasKey || !hasValue) {
throw new TypeError(`Filter object at index ${index} must have 'key' and 'value' properties`);
}
// Reconstruct filter syntax
const filterString = `[${segment.key}=${JSON.stringify(segment.value)}]`;
parts.push(filterString);
continue;
}
// 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}`);
}
if (typeof segment === 'number') {
// Handle numeric indices
if (!Number.isInteger(segment) || segment < 0) {
// Non-integer or negative numbers are treated as string keys
const escaped = escapePath(String(segment));
parts.push(index === 0 ? escaped : `.${escaped}`);
} else if (preferDotForIndices && index > 0) {
parts.push(`.${segment}`);
} else {
parts.push(`[${segment}]`);
}
} else if (typeof segment === 'string') {
if (segment === '') {
// Empty string handling
if (index === 0) {
// Start with empty string, no prefix needed
} else {
parts.push('.');
}
} else if (shouldCoerceToNumber(segment)) {
// Numeric strings are normalized to numbers
const numericValue = Number.parseInt(segment, 10);
if (preferDotForIndices && index > 0) {
parts.push(`.${numericValue}`);
} else {
parts.push(`[${numericValue}]`);
}
} else {
// Regular strings use dot notation
const escaped = escapePath(segment);
parts.push(index === 0 ? escaped : `.${escaped}`);
}
}
}
return parts.join('');
}
function * deepKeysIterator(object, currentPath = [], ancestors = new Set()) {
if (!isObject(object) || isEmptyObject(object)) {
if (currentPath.length > 0) {
yield stringifyPath(currentPath);
}
return;
}
// Check if this object is already in the current path (circular reference)
if (ancestors.has(object)) {
return;
}
// Add to ancestors, recurse, then remove (backtrack)
ancestors.add(object);
// Reuse currentPath array by push/pop instead of creating new arrays
for (const [key, value] of normalizeEntries(object)) {
currentPath.push(key);
yield * deepKeysIterator(value, currentPath, ancestors);
currentPath.pop();
}
ancestors.delete(object);
}
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;
}