Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support array indexes #82

Merged
merged 14 commits into from Jan 21, 2022
17 changes: 12 additions & 5 deletions index.d.ts
Expand Up @@ -4,7 +4,7 @@ declare const dotProp: {
/**
Get the value of the property at the given path.

@param object - Object to get the `path` value.
@param object - Object or array to get the `path` value.
@param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key.
@param defaultValue - Default value.

Expand All @@ -23,18 +23,21 @@ declare const dotProp: {

dotProp.get({foo: {'dot.dot': 'unicorn'}}, 'foo.dot\\.dot');
//=> 'unicorn'

dotProp.get({foo: [{bar: 'unicorn'}]}, 'foo[0].bar');
//=> 'unicorn'
```
*/
get: <ObjectType, PathType extends string, DefaultValue = undefined>(
object: ObjectType,
path: PathType,
defaultValue?: DefaultValue
) => ObjectType extends Record<string, unknown> ? (Get<ObjectType, PathType> extends unknown ? DefaultValue : Get<ObjectType, PathType>) : undefined; // TODO: When adding array index support (https://github.com/sindresorhus/dot-prop/issues/71) add ` | unknown[]` after `Record<string, unknown>`
) => ObjectType extends Record<string, unknown> | unknown[] ? (Get<ObjectType, PathType> extends unknown ? DefaultValue : Get<ObjectType, PathType>) : undefined;

/**
Set the property at the given path to the given value.

@param object - Object to set the `path` value.
@param object - Object or array to set the `path` value.
@param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key.
@param value - Value to set at `path`.
@returns The object.
Expand All @@ -55,6 +58,10 @@ declare const dotProp: {
dotProp.set(object, 'foo.baz', 'x');
console.log(object);
//=> {foo: {bar: 'b', baz: 'x'}}

dotProp.set(object, 'foo.biz[0]', 'a');
console.log(object);
//=> {foo: {bar: 'b', baz: 'x', biz: ['a']}}
```
*/
set: <ObjectType extends {[key: string]: any}>(
Expand All @@ -66,7 +73,7 @@ declare const dotProp: {
/**
Check whether the property at the given path exists.

@param object - Object to test the `path` value.
@param object - Object or array to test the `path` value.
@param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key.

@example
Expand All @@ -82,7 +89,7 @@ declare const dotProp: {
/**
Delete the property at the given path.

@param object - Object to delete the `path` value.
@param object - Object or array to delete the `path` value.
@param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key.
@returns A boolean of whether the property existed before being deleted.

Expand Down
162 changes: 148 additions & 14 deletions index.js
Expand Up @@ -7,30 +7,156 @@ const disallowedKeys = new Set([
'constructor'
]);

const isValidPath = pathSegments => !pathSegments.some(segment => disallowedKeys.has(segment));
const digits = new Set('0123456789');
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved

function getPathSegments(path) {
const pathArray = path.split('.');
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;

for (let i = 0; i < pathArray.length; i++) {
let p = pathArray[i];
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;

while (p[p.length - 1] === '\\' && pathArray[i + 1] !== undefined) {
p = p.slice(0, -1) + '.';
p += pathArray[++i];
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
Richienb marked this conversation as resolved.
Show resolved Hide resolved

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;
}
}

parts.push(p);
if (isIgnoring) {
currentSegment += '\\';
}

if (!isValidPath(parts)) {
return [];
if (currentPart === 'property') {
if (disallowedKeys.has(currentSegment)) {
return [];
}

parts.push(currentSegment);
} else if (currentPart === 'index') {
throw new Error('Index was not closed');
} else if (currentPart === 'start') {
parts.push('');
}

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');
}
}

module.exports = {
get(object, path, value) {
if (!isObject(object) || typeof path !== 'string') {
Expand All @@ -43,12 +169,18 @@ module.exports = {
}

for (let i = 0; i < pathArray.length; i++) {
object = object[pathArray[i]];
const key = pathArray[i];

if (isStringIndex(object, key)) {
object = i === pathArray.length - 1 ? undefined : null;
Richienb marked this conversation as resolved.
Show resolved Hide resolved
} 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 did't return `undefined`
// 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 (i !== pathArray.length - 1) {
Expand All @@ -72,9 +204,10 @@ module.exports = {

for (let i = 0; i < pathArray.length; i++) {
const p = pathArray[i];
assertNotStringIndex(object, p);

if (!isObject(object[p])) {
object[p] = {};
object[p] = Number.isInteger(pathArray[i + 1]) ? [] : {};
}

if (i === pathArray.length - 1) {
Expand All @@ -96,6 +229,7 @@ module.exports = {

for (let i = 0; i < pathArray.length; i++) {
const p = pathArray[i];
assertNotStringIndex(object, p);

if (i === pathArray.length - 1) {
delete object[p];
Expand Down Expand Up @@ -123,7 +257,7 @@ module.exports = {
// eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < pathArray.length; i++) {
if (isObject(object)) {
if (!(pathArray[i] in object)) {
if (!(pathArray[i] in object && !isStringIndex(object, pathArray[i]))) {
return false;
}

Expand Down
11 changes: 9 additions & 2 deletions readme.md
Expand Up @@ -26,6 +26,9 @@ dotProp.get({foo: {bar: 'a'}}, 'foo.notDefined.deep', 'default value');
dotProp.get({foo: {'dot.dot': 'unicorn'}}, 'foo.dot\\.dot');
//=> 'unicorn'

dotProp.get({foo: [{bar: 'unicorn'}]}, 'foo[0].bar');
//=> 'unicorn'

// Setter
const object = {foo: {bar: 'a'}};
dotProp.set(object, 'foo.bar', 'b');
Expand All @@ -40,6 +43,10 @@ dotProp.set(object, 'foo.baz', 'x');
console.log(object);
//=> {foo: {bar: 'b', baz: 'x'}}

dotProp.set(object, 'foo.biz.0', 'a');
console.log(object);
//=> {foo: {bar: 'b', baz: 'x', biz: ['a']}}

// Has
dotProp.has({foo: {bar: 'unicorn'}}, 'foo.bar');
//=> true
Expand Down Expand Up @@ -84,9 +91,9 @@ Returns a boolean of whether the property existed before being deleted.

#### object

Type: `object`
Type: `object | array`

Object to get, set, or delete the `path` value.
Object or array to get, set, or delete the `path` value.

You are allowed to pass in `undefined` as the object to the `get` and `has` functions.

Expand Down