Skip to content

Commit

Permalink
feat: merging nested arrays
Browse files Browse the repository at this point in the history
  • Loading branch information
dimadeveatii committed Jul 28, 2022
1 parent eea8c9b commit dc81d12
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 22 deletions.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,72 @@ student.updateOne(
);
```

### Merge array documents

Using positional operator to merge fields into the matched element:

```ts
import { flatten, $, $inc, $currentDate } from 'mongo-dot-notation';

const student = {
grades: $().merge({
class: '101',
prof: 'Alice',
value: $inc(),
date: $currentDate(),
}),
};

flatten(student);
```

Result:

```json
{
"$set": {
"grades.$.class": "101",
"grades.$.prof": "Alice"
},
"$inc": {
"grades.$.value": 1
},
"$currentDate": {
"grades.$.date": { "$type": "date" }
}
}
```

To update all elements, use `$('[]')` instead of `$()` in the above example.

### Update nested arrays

Using positional operator to update nested arrays:

```ts
import { flatten, $, $mul } from 'mongo-dot-notation';

const student = {
grades: $().merge({
questions: $('[]').merge({
value: $mul(100),
}),
}),
};

flatten(student);
```

Calling `flatten(student)` results in:

```json
{
"$mul": {
"grades.$.questions.$[].value": 100
}
}
```

See the [end-to-end](tests/mongo.e2e.ts) tests file for more examples.

## API
Expand Down Expand Up @@ -573,6 +639,8 @@ collection.updateOne(criteria, flatten({ grades: $('[element].grade').$inc(10) }
});
```

See [update nested arrays](#update-nested-arrays) for examples using `$().merge`.

[MongoDB manual](https://www.mongodb.com/docs/manual/reference/operator/update/positional/)

### $addToSet
Expand Down
24 changes: 24 additions & 0 deletions src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const positional = (field?: number | string) => {
return `$${field}`;
}

if (field === '') {
return '$';
}

return `$.${field}`;
};

Expand Down Expand Up @@ -129,6 +133,26 @@ const sort =
export const $ = (field?: number | string) => {
const key = positional(field);
return create(key, undefined, {
/**
* Merges the array element(s) identified by the current positional operator
* with the given object.
* @see https://www.mongodb.com/docs/manual/reference/operator/update/positional-all/#nested-arrays
* @param value object to merge
*
* @example
* ```ts
* flatten({ points: $('[]').merge({ x: 0, y: 1 }) });
*
* // {
* // $set: {
* // 'points.$[].x': 1,
* // 'points.$[].y': 2,
* // }
* // }
* ```
*/
merge: <T extends Record<string, any>>(value: T) => create(key, create('merge', value)),

/**
* @see {@link $inc}
*/
Expand Down
59 changes: 37 additions & 22 deletions src/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,40 @@ export const flatten = <T extends Record<string, any>>(value: T, options?: Optio
return keyValues.reduce((acc, [key, value]) => d(acc, key, value), {});
};

const mergeInner = <T>(
obj: Record<string, any>,
field: string,
inner: string,
value: T
): Record<string, any> => ({
...obj,
[field]: {
...obj[field],
[inner]: value,
},
});

const dot = (options: Required<Options>) => {
const merge = <T>(
instructions: Record<string, any>,
operator: string,
field: string,
value: T
): Record<string, any> => {
if (!isOperator(value)) {
return mergeInner(instructions, operator, field, value);
}

if (getType(value) === 'merge') {
const mergeValue = getValue(value);
return isNullOrUndefined(mergeValue)
? instructions
: dotMerge(instructions, `${field}.${operator}`, mergeValue);
}

return mergeInner(instructions, getType(value), `${field}.${operator}`, getValue(value));
};

const visit = <T>(
instructions: Record<string, any>,
field: string,
Expand Down Expand Up @@ -133,24 +166,7 @@ const dot = (options: Required<Options>) => {
return visit;
};

const merge = <T>(
instructions: Record<string, any>,
operator: string,
field: string,
value: T
): Record<string, any> => {
if (isOperator(value)) {
return merge(instructions, getType(value), `${field}.${operator}`, getValue(value));
}

return {
...instructions,
[operator]: {
...instructions[operator],
[field]: value,
},
};
};
const dotMerge = dot({ array: true, skipEmptyObjects: true });

const isAtomic = <T>(value: T) => isPrimitive(value) || isBsonType(value);

Expand All @@ -161,14 +177,13 @@ const INSTANCEOF_PRIMITIVES = [Date, RegExp, typeof Buffer !== undefined ? Buffe
.filter(Boolean);

const isPrimitive = <T = any>(value: T) => {
if (value === null || typeof value === 'undefined') {
return true;
}

return (
isNullOrUndefined(value) ||
TYPEOF_PRIMITIVES.some((type) => typeof value === type) ||
INSTANCEOF_PRIMITIVES.some((type) => value instanceof type)
);
};

const isNullOrUndefined = <T>(value: T) => value === null || typeof value === 'undefined';

const isBsonType = (value: any) => '_bsontype' in value;
2 changes: 2 additions & 0 deletions tests/array.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('Array update operators', () => {

it.each([
['$', $()],
['$', $('')],
['$.name', $('name')],
['$.name.first', $('name.first')],
['0', $(0)],
Expand Down Expand Up @@ -42,6 +43,7 @@ describe('Array update operators', () => {
['$currentDate', $().$currentDate('date'), { $type: 'date' }],
['$currentDate', $().$currentDate('timestamp'), { $type: 'timestamp' }],
['$currentDate', $().$timestamp(), { $type: 'timestamp' }],
['merge', $().merge({ x: 7 }), { x: 7 }],
])('.%s()', (type, operator, value) => {
it('Should be operator', () => {
expect(isOperator(operator)).toStrictEqual(true);
Expand Down
41 changes: 41 additions & 0 deletions tests/flatten.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,32 @@ describe('flatten()', () => {
});
});

describe.each([
[{ x: $().merge({}) }, {}],
[{ x: $().merge(null as any) }, {}],
[{ x: $('[]').merge({}) }, {}],
[{ x: $().merge({ a: {}, b: 2 }) }, { $set: { 'x.$.b': 2 } }],
[{ x: $('[]').merge({ a: {}, b: 2 }) }, { $set: { 'x.$[].b': 2 } }],
[{ x: $('w').merge({ a: {}, b: 2 }) }, { $set: { 'x.$.w.b': 2 } }],
[{ x: $().merge({ y: 1 }) }, { $set: { 'x.$.y': 1 } }],
[{ x: $().merge({ y: $inc(99) }) }, { $inc: { 'x.$.y': 99 } }],
[{ x: $('[]').merge({ y: 1 }) }, { $set: { 'x.$[].y': 1 } }],
[{ x: $('[filter]').merge({ y: 1 }) }, { $set: { 'x.$[filter].y': 1 } }],
[{ x: $().merge({ a: { b: { c: 'd' } } }) }, { $set: { 'x.$.a.b.c': 'd' } }],
[
{ x: $('[]').merge({ y: $('[elem]').merge({ z: 'test' }) }) },
{ $set: { 'x.$[].y.$[elem].z': 'test' } },
],
[
{ x: $('[].a').merge({ w: 42, y: $('[elem].b').merge({ z: { A: 'X', B: 'Y' } }) }) },
{ $set: { 'x.$[].a.y.$[elem].b.z.A': 'X', 'x.$[].a.y.$[elem].b.z.B': 'Y', 'x.$[].a.w': 42 } },
],
])('When using nested arrays', (input, expected) => {
it('should verify value', () => {
expect(flatten(input)).toStrictEqual(expected);
});
});

describe('GitHub issues', () => {
describe('issues/7', () => {
it('Should flatten array inside of object', () => {
Expand All @@ -326,5 +352,20 @@ describe('flatten()', () => {
});
});
});

describe('issues/10', () => {
it('Should merge fields inside of array', () => {
const data = {
address: $().merge({ nr: 7, code: 'AB1234' }),
};

expect(flatten(data)).toStrictEqual({
$set: {
'address.$.nr': 7,
'address.$.code': 'AB1234',
},
});
});
});
});
});
78 changes: 78 additions & 0 deletions tests/mongo.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,84 @@ describe('End-to-end tests', () => {
);
expect(user.scores).toStrictEqual([0, 9, 99]);
});

describe('Nested arrays', () => {
let students: Collection<any>;

beforeEach(() => {
students = db.collection('students');
});

afterEach(() => students.drop());

it('Update filtered elements', async () => {
await students.insertOne({
_id: 1,
grades: [
{ type: 'quiz', questions: [10, 8, 5] },
{ type: 'quiz', questions: [8, 9, 6] },
{ type: 'hw', questions: [5, 4, 3] },
{ type: 'exam', questions: [25, 10, 23, 0] },
],
});

const data = {
grades: $('[t]').merge({
questions: $('[score]').$inc(2),
}),
};

await students.updateMany({}, flatten(data), {
arrayFilters: [{ 't.type': 'quiz' }, { score: { $gte: 8 } }],
});

const value = await students.findOne({ _id: 1 });

expect(value).toStrictEqual({
_id: 1,
grades: [
{ type: 'quiz', questions: [12, 10, 5] },
{ type: 'quiz', questions: [10, 11, 6] },
{ type: 'hw', questions: [5, 4, 3] },
{ type: 'exam', questions: [25, 10, 23, 0] },
],
});
});

it('Update all elements', async () => {
await students.insertOne({
_id: 1,
grades: [
{ type: 'quiz', questions: [10, 8, 5] },
{ type: 'quiz', questions: [8, 9, 6] },
{ type: 'hw', questions: [5, 4, 3] },
{ type: 'exam', questions: [25, 10, 23, 0] },
],
});

const data = {
grades: $('[]').merge({
questions: $('[score]').$inc(2),
}),
};

await students.updateMany({}, flatten(data), {
arrayFilters: [{ score: { $gte: 8 } }],
});

const value = await students.findOne({ _id: 1 });

expect(value).toStrictEqual({
_id: 1,
grades: [
{ type: 'quiz', questions: [12, 10, 5] },
{ type: 'quiz', questions: [10, 11, 6] },
{ type: 'hw', questions: [5, 4, 3] },
{ type: 'exam', questions: [27, 12, 25, 0] },
],
});
});
});
});

describe('Bitwise operators', function () {
Expand Down

0 comments on commit dc81d12

Please sign in to comment.