Skip to content

Commit

Permalink
fix(get): ensure get returns flat array for deeply nested object of…
Browse files Browse the repository at this point in the history
… arrays of object arrays (#13)

* fix(js/utils.ts): make getField() output flat array of items
* test(js/getobjectfield.spec): test if it returns property values from nested array as flat array
* refactor(js/interpreters.js): remove now unnecessary check for projected field use testValueOrArray with within(), reorder array check conditions
* test(js/interpreters.spec): check that within, all and elemMatch works with deeply nested array
* refactor(js/index): remove projected-field export as it's not used anymore
* test(js/interpreters.spec): add test that catch a bug introduce by reducing values
* fix($size): ensure `$size` operator properly works for nested fields and array of arrays

Co-authored-by: Jérémy <jeremy.huard@doeo.fr>
Co-authored-by: Sergii <sergiy.stotskiy@gmail.com>
  • Loading branch information
3 people committed Aug 24, 2020
1 parent 75487dd commit 2efeb91
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 59 deletions.
5 changes: 5 additions & 0 deletions packages/js/spec/getObjectField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ describe('getObjectField', () => {
expect(getObjectField(object, 'items.price')).to.deep.equal([12, 14])
})

it('returns property values from nested array as flat array', () => {
const object = { items: [{ specs: [{ price: 12 }] }, { specs: [{ price: 14 }] }] }
expect(getObjectField(object, 'items.specs.price')).to.deep.equal([12, 14])
})

it('returns array item when specified number as the last path field', () => {
const object = { items: [{ price: 12 }, { price: 14 }] }
expect(getObjectField(object, 'items.0')).to.deep.equal({ price: 12 })
Expand Down
55 changes: 44 additions & 11 deletions packages/js/spec/interpreters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
nor,
not,
createJsInterpreter,
compare as defaultCompare
compare as defaultCompare,
} from '../src'
import {
includeExamplesForFieldCondition,
Expand Down Expand Up @@ -321,6 +321,7 @@ describe('Condition Interpreter', () => {
const condition = new Field('size', 'items.a', 2)

expect(interpret(condition, { items: [{ a: [2, 3] }, { a: [] }, {}] })).to.be.true
expect(interpret(condition, { items: [{ a: [2, 3] }, { a: [4] }, { a: [2] }] })).to.be.true
expect(interpret(condition, { items: [5, 4] })).to.be.false
expect(interpret(condition, { items: { a: [2, 3] } })).to.be.true
})
Expand Down Expand Up @@ -423,6 +424,15 @@ describe('Condition Interpreter', () => {

expect(compare).to.have.been.called.with(1, 1)
})

it('checks that at least one item from array satisfies condition', () => {
const condition = new Field('within', 'items.specs.age', [1])

expect(interpret(condition, { items: [] })).to.be.false
expect(interpret(condition, { items: [{ specs: [{ age: 2 }] }] })).to.be.false
expect(interpret(condition, { items: [{ specs: [{ age: 1 }] }] })).to.be.true
expect(interpret(condition, { items: [{ specs: 'test' }, { specs: [{ age: 1 }] }] })).to.be.true
})
})

describe('nin', () => {
Expand Down Expand Up @@ -474,6 +484,16 @@ describe('Condition Interpreter', () => {

expect(compare).to.have.been.called.with(1, 1)
})

it('checks that at least one item from array satisfies condition', () => {
const condition = new Field('all', 'items.prices', [1, 2, 3])

expect(interpret(condition, { items: [] })).to.be.false
expect(interpret(condition, { items: [{ prices: [1, 2] }] })).to.be.false
expect(interpret(condition, { items: [{ prices: condition.value }] })).to.be.true
expect(interpret(condition, { items: [{ names: ['test'] }, { prices: condition.value }] }))
.to.be.true
})
})

describe('elemMatch', () => {
Expand All @@ -487,26 +507,39 @@ describe('Condition Interpreter', () => {
})

it('checks that field value item matches all conditions', () => {
const condition = new Field('elemMatch', 'items', new CompoundCondition('and', [
new Field('eq', 'age', 1),
new Field('eq', 'active', true)
]))
const condition = new Field(
'elemMatch',
'items',
new CompoundCondition('and', [new Field('eq', 'age', 1), new Field('eq', 'active', true)])
)
const item = (items: object[]) => ({ items })

expect(interpret(condition, item([{ age: 1 }]))).to.be.false
expect(interpret(condition, item([]))).to.be.false
expect(interpret(condition, item([{ age: 1 }, { active: true }]))).to.be.false
expect(interpret(condition, item([{ age: 1 }, { age: 1, active: true }]))).to.be.true
})

it('checks that at least one item from array satisfies condition', () => {
const condition = new Field(
'elemMatch',
'items.subItems',
new CompoundCondition('and', [new Field('eq', 'age', 1), new Field('eq', 'active', true)])
)
const item = (subItems: object[]) => ({ items: [{ subItems }] })

expect(interpret(condition, item([{ age: 1 }]))).to.be.false
expect(interpret(condition, item([]))).to.be.false
expect(interpret(condition, item([{ age: 1, active: true }]))).to.be.true
expect(interpret(condition, item([{ age: 1 }, { age: 1, active: true }]))).to.be.true
})
})

describe('not', () => {
const interpret = createJsInterpreter({ not, eq })

it('inverts nested condition', () => {
const condition = new CompoundCondition('not', [
new Field('eq', 'age', 12),
])
const condition = new CompoundCondition('not', [new Field('eq', 'age', 12)])

expect(interpret(condition, { age: 12 })).to.be.false
expect(interpret(condition, { age: 13 })).to.be.true
Expand All @@ -519,7 +552,7 @@ describe('Condition Interpreter', () => {
it('combines conditions using logical "and"', () => {
const condition = new CompoundCondition('and', [
new Field('eq', 'age', 1),
new Field('eq', 'active', true)
new Field('eq', 'active', true),
])

expect(interpret(condition, { age: 1, active: true })).to.be.true
Expand All @@ -533,7 +566,7 @@ describe('Condition Interpreter', () => {
it('combines conditions using logical "or"', () => {
const condition = new CompoundCondition('or', [
new Field('eq', 'age', 1),
new Field('eq', 'active', true)
new Field('eq', 'active', true),
])

expect(interpret(condition, { age: 1, active: true })).to.be.true
Expand All @@ -549,7 +582,7 @@ describe('Condition Interpreter', () => {
it('combines conditions using logical "not or"', () => {
const condition = new CompoundCondition('nor', [
new Field('eq', 'age', 1),
new Field('eq', 'active', true)
new Field('eq', 'active', true),
])

expect(interpret(condition, { age: 1, active: true })).to.be.false
Expand Down
1 change: 0 additions & 1 deletion packages/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './interpreters';
export * from './interpreter';
export * from './defaults';
export { PROJECTED_FIELD } from './utils';
export type { JsInterpretationOptions, JsInterpreter } from './types';
13 changes: 13 additions & 0 deletions packages/js/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { JsInterpretationOptions, JsInterpreter } from './types';
const defaultGet = (object: AnyObject, field: string) => object[field];
type Field = string | typeof ITSELF;

export function getObjectFieldCursor<T extends {}>(object: T, path: string, get: GetField) {
const dotIndex = path.lastIndexOf('.');

if (dotIndex === -1) {
return [object, path] as const;
}

return [
get(object, path.slice(0, dotIndex)) as T,
path.slice(dotIndex + 1)
] as const;
}

export function getObjectField(object: unknown, field: Field, get: GetField = defaultGet) {
if (field === ITSELF) {
return object;
Expand Down
59 changes: 17 additions & 42 deletions packages/js/src/interpreters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
testValueOrArray,
isArrayAndNotNumericField,
AnyObject,
PROJECTED_FIELD
} from './utils';
import { getObjectFieldCursor } from './interpreter';

export const or: Interpret<Compound> = (node, object, { interpret }) => {
return node.value.some(condition => interpret(condition, object));
Expand Down Expand Up @@ -66,16 +66,9 @@ export const exists: Interpret<Field<boolean>> = (node, object, { get }) => {
return typeof object !== 'undefined';
}

let item = object;
let field = node.field;
const dotIndex = node.field.lastIndexOf('.');
const [item, field] = getObjectFieldCursor<{}>(object, node.field, get);
const test = (value: {}) => !!value && value.hasOwnProperty(field) === node.value;

if (dotIndex !== -1) {
field = node.field.slice(dotIndex + 1);
item = get(object, node.field.slice(0, dotIndex));
}

return isArrayAndNotNumericField(item, field) ? item.some(test) : test(item);
};

Expand All @@ -84,51 +77,33 @@ export const mod = testValueOrArray<[number, number], number>((node, value) => {
});

export const size: Interpret<Field<number>, AnyObject | unknown[]> = (node, object, { get }) => {
const value = get(object, node.field);

if (!Array.isArray(value)) {
return false;
}

return value.hasOwnProperty(PROJECTED_FIELD)
? value.some(v => Array.isArray(v) && v.length === node.value)
: value.length === node.value;
const [items, field] = getObjectFieldCursor(object as AnyObject, node.field, get);
const test = (item: unknown) => {
const value = get(item, field);
return Array.isArray(value) && value.length === node.value;
};

return node.field !== ITSELF && isArrayAndNotNumericField(items, field)
? items.some(test)
: test(items);
};

export const regex = testValueOrArray<RegExp, string>((node, value) => node.value.test(value));

export const within: Interpret<Field<unknown[]>> = (node, object, { equal, get }) => {
const value = get(object, node.field);

if (Array.isArray(value)) {
return node.value.some(item => includes(value, item, equal));
}

return includes(node.value, value, equal);
};
export const within = testValueOrArray<unknown[], unknown>((node, object, { equal }) => {
return includes(node.value, object, equal);
});

export const nin: typeof within = (node, object, context) => {
return !within(node, object, context);
};
export const nin: typeof within = (node, object, context) => !within(node, object, context);

export const all: Interpret<Field<unknown[]>> = (node, object, { equal, get }) => {
const value = get(object, node.field);

if (Array.isArray(value)) {
return node.value.every(v => includes(value, v, equal));
}

return false;
return Array.isArray(value) && node.value.every(v => includes(value, v, equal));
};

export const elemMatch: Interpret<Field<Condition>> = (node, object, { interpret, get }) => {
const value = get(object, node.field);

if (!Array.isArray(value)) {
return false;
}

return value.some(v => interpret(node.value, v));
return Array.isArray(value) && value.some(v => interpret(node.value, v));
};

type WhereFunction = (this: AnyObject) => boolean;
Expand Down
14 changes: 9 additions & 5 deletions packages/js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { JsInterpretationOptions, JsInterpreter } from './types';
export type AnyObject = Record<PropertyKey, unknown>;
export type GetField = (object: any, field: string) => any;

export function includes<T>(items: T[], value: T, equal: JsInterpretationOptions['equal']): boolean {
export function includes<T>(
items: T[],
value: T,
equal: JsInterpretationOptions['equal']
): boolean {
for (let i = 0, length = items.length; i < length; i++) {
if (equal(items[i], value)) {
return true;
Expand All @@ -14,16 +18,16 @@ export function includes<T>(items: T[], value: T, equal: JsInterpretationOptions
return false;
}

export const PROJECTED_FIELD = typeof Symbol === 'undefined' ? '__projected' : Symbol('projected');

export function isArrayAndNotNumericField<T>(object: T | T[], field: string): object is T[] {
return Array.isArray(object) && Number.isNaN(Number(field));
}

function getField<T extends AnyObject>(object: T | T[], field: string, get: GetField) {
if (isArrayAndNotNumericField(object, field)) {
const items = object.map(item => get(item, field));
return Object.defineProperty(items, PROJECTED_FIELD, { value: true });
return object.reduce((acc, item) => {
const value = get(item, field);
return value !== undefined ? acc.concat(value) : acc;
}, []);
}

return get(object, field);
Expand Down

0 comments on commit 2efeb91

Please sign in to comment.