Browse Source

refactor: improve filtering

e22m4u 1 month ago
parent
commit
51bf14b1fa

+ 52 - 48
dist/cjs/index.cjs

@@ -687,8 +687,6 @@ var init_operator_clause_tool = __esm({
     "use strict";
     import_js_service3 = require("@e22m4u/js-service");
     init_utils();
-    init_utils();
-    init_errors();
     init_errors();
     _OperatorClauseTool = class _OperatorClauseTool extends import_js_service3.Service {
       /**
@@ -696,34 +694,37 @@ var init_operator_clause_tool = __esm({
        *
        * @param {*} val1 The 1st value
        * @param {*} val2 The 2nd value
+       * @param {*} noTypeConversion
        * @returns {number} 0: =, positive: >, negative <
        */
-      compare(val1, val2) {
+      compare(val1, val2, noTypeConversion = false) {
+        if (val1 === val2) {
+          return 0;
+        }
         if (val1 == null || val2 == null) {
           return val1 == val2 ? 0 : NaN;
         }
-        if (typeof val1 === "number") {
-          if (typeof val2 === "number" || typeof val2 === "string" || typeof val2 === "boolean") {
-            if (val1 === val2) return 0;
-            return val1 - Number(val2);
-          }
-          return NaN;
+        const type1 = typeof val1;
+        const type2 = typeof val2;
+        if (type1 === "object" || type2 === "object") {
+          return isDeepEqual(val1, val2) ? 0 : NaN;
         }
-        if (typeof val1 === "string") {
-          const isDigits = /^\d+$/.test(val1);
-          if (isDigits) return this.compare(Number(val1), val2);
-          try {
-            if (val1 > val2) return 1;
-            if (val1 < val2) return -1;
-            if (val1 == val2) return 0;
-          } catch (e) {
+        if ((type1 === "number" || type1 === "string" || type1 === "boolean") && (type2 === "number" || type2 === "string" || type2 === "boolean")) {
+          if (noTypeConversion && type1 !== type2) {
+            return NaN;
+          }
+          const num1 = Number(val1);
+          const num2 = Number(val2);
+          if (!isNaN(num1) && !isNaN(num2)) {
+            return num1 - num2;
           }
-          return NaN;
         }
-        if (typeof val1 === "boolean") {
-          return Number(val1) - Number(val2);
+        if (type1 === "string" && type2 === "string") {
+          if (val1 > val2) return 1;
+          if (val1 < val2) return -1;
+          return 0;
         }
-        return val1 === val2 ? 0 : NaN;
+        return NaN;
       }
       /**
        * Test all operators.
@@ -797,8 +798,8 @@ var init_operator_clause_tool = __esm({
             "The first argument of OperatorUtils.testEqNeq should be an Object, but %v was given.",
             clause
           );
-        if ("eq" in clause) return this.compare(clause.eq, value) === 0;
-        if ("neq" in clause) return this.compare(clause.neq, value) !== 0;
+        if ("eq" in clause) return this.compare(clause.eq, value, true) === 0;
+        if ("neq" in clause) return this.compare(clause.neq, value, true) !== 0;
       }
       /**
        * Test lt/gt/lte/gte operator.
@@ -875,7 +876,9 @@ var init_operator_clause_tool = __esm({
             );
           }
           for (let i = 0; i < clause.inq.length; i++) {
-            if (clause.inq[i] == value) return true;
+            if (this.compare(clause.inq[i], value, true) === 0) {
+              return true;
+            }
           }
           return false;
         }
@@ -908,10 +911,9 @@ var init_operator_clause_tool = __esm({
               clause.nin
             );
           }
-          for (let i = 0; i < clause.nin.length; i++) {
-            if (clause.nin[i] == value) return false;
-          }
-          return true;
+          return clause.nin.every((element) => {
+            return this.compare(element, value, true) !== 0;
+          });
         }
       }
       /**
@@ -1146,9 +1148,9 @@ var init_where_clause_tool = __esm({
   "src/filter/where-clause-tool.js"() {
     "use strict";
     import_js_service4 = require("@e22m4u/js-service");
-    init_utils();
     init_errors();
     init_operator_clause_tool();
+    init_utils();
     _WhereClauseTool = class _WhereClauseTool extends import_js_service4.Service {
       /**
        * Filter by where clause.
@@ -1219,21 +1221,6 @@ var init_where_clause_tool = __esm({
             }
             const value = getValueByPath(data, key);
             const matcher = whereClause[key];
-            if (Array.isArray(value)) {
-              if (typeof matcher === "object" && matcher !== null && "neq" in matcher && matcher.neq !== void 0) {
-                if (value.length === 0) return true;
-                return value.every((el, index) => {
-                  const where = {};
-                  where[index] = matcher;
-                  return this._createFilter(where)({ ...value });
-                });
-              }
-              return value.some((el, index) => {
-                const where = {};
-                where[index] = matcher;
-                return this._createFilter(where)({ ...value });
-              });
-            }
             if (this._test(matcher, value)) return true;
           });
         };
@@ -1246,6 +1233,9 @@ var init_where_clause_tool = __esm({
        * @returns {boolean}
        */
       _test(example, value) {
+        if (example === value) {
+          return true;
+        }
         if (example === null) {
           return value === null;
         }
@@ -1253,17 +1243,31 @@ var init_where_clause_tool = __esm({
           return value === void 0;
         }
         if (example instanceof RegExp) {
-          if (typeof value === "string") return !!value.match(example);
+          if (typeof value === "string") {
+            return example.test(value);
+          }
+          if (Array.isArray(value)) {
+            return value.some((el) => typeof el === "string" && example.test(el));
+          }
           return false;
         }
-        if (typeof example === "object" && !Array.isArray(example)) {
+        if (isPureObject(example)) {
           const operatorsTest = this.getService(OperatorClauseTool).testAll(
             example,
             value
           );
-          if (operatorsTest !== void 0) return operatorsTest;
+          if (operatorsTest !== void 0) {
+            if ("neq" in example && Array.isArray(value)) {
+              return !value.some((el) => isDeepEqual(el, example.neq));
+            }
+            return operatorsTest;
+          }
+        }
+        if (Array.isArray(value)) {
+          const isElementMatched = value.some((el) => isDeepEqual(el, example));
+          if (isElementMatched) return true;
         }
-        return example == value;
+        return isDeepEqual(example, value);
       }
       /**
        * Validate where clause.

+ 2 - 1
src/filter/operator-clause-tool.d.ts

@@ -9,8 +9,9 @@ export declare class OperatorClauseTool extends Service {
    *
    * @param val1
    * @param val2
+   * @param noTypeConversion
    */
-  compare(val1: unknown, val2: unknown): number;
+  compare(val1: unknown, val2: unknown, noTypeConversion?: boolean): number;
 
   /**
    * Test all operators.

+ 42 - 36
src/filter/operator-clause-tool.js

@@ -1,8 +1,10 @@
 import {Service} from '@e22m4u/js-service';
-import {likeToRegexp} from '../utils/index.js';
-import {stringToRegexp} from '../utils/index.js';
-import {InvalidArgumentError} from '../errors/index.js';
-import {InvalidOperatorValueError} from '../errors/index.js';
+import {isDeepEqual, likeToRegexp, stringToRegexp} from '../utils/index.js';
+
+import {
+  InvalidArgumentError,
+  InvalidOperatorValueError,
+} from '../errors/index.js';
 
 /**
  * Operator clause tool.
@@ -13,40 +15,43 @@ export class OperatorClauseTool extends Service {
    *
    * @param {*} val1 The 1st value
    * @param {*} val2 The 2nd value
+   * @param {*} noTypeConversion
    * @returns {number} 0: =, positive: >, negative <
    */
-  compare(val1, val2) {
+  compare(val1, val2, noTypeConversion = false) {
+    if (val1 === val2) {
+      return 0;
+    }
     if (val1 == null || val2 == null) {
       return val1 == val2 ? 0 : NaN;
     }
-    if (typeof val1 === 'number') {
-      if (
-        typeof val2 === 'number' ||
-        typeof val2 === 'string' ||
-        typeof val2 === 'boolean'
-      ) {
-        if (val1 === val2) return 0;
-        return val1 - Number(val2);
-      }
-      return NaN;
+    const type1 = typeof val1;
+    const type2 = typeof val2;
+    // объекты и массивы
+    if (type1 === 'object' || type2 === 'object') {
+      return isDeepEqual(val1, val2) ? 0 : NaN;
     }
-    if (typeof val1 === 'string') {
-      const isDigits = /^\d+$/.test(val1);
-      if (isDigits) return this.compare(Number(val1), val2);
-      try {
-        if (val1 > val2) return 1;
-        if (val1 < val2) return -1;
-        if (val1 == val2) return 0;
-      } catch (e) {
-        /**/
+    // числовое сравнение
+    if (
+      (type1 === 'number' || type1 === 'string' || type1 === 'boolean') &&
+      (type2 === 'number' || type2 === 'string' || type2 === 'boolean')
+    ) {
+      if (noTypeConversion && type1 !== type2) {
+        return NaN;
+      }
+      const num1 = Number(val1);
+      const num2 = Number(val2);
+      if (!isNaN(num1) && !isNaN(num2)) {
+        return num1 - num2;
       }
-      return NaN;
     }
-    if (typeof val1 === 'boolean') {
-      return Number(val1) - Number(val2);
+    // лексикографическое сравнение
+    if (type1 === 'string' && type2 === 'string') {
+      if (val1 > val2) return 1;
+      if (val1 < val2) return -1;
+      return 0;
     }
-    // Return NaN if we don't know how to compare.
-    return val1 === val2 ? 0 : NaN;
+    return NaN;
   }
 
   /**
@@ -131,8 +136,8 @@ export class OperatorClauseTool extends Service {
           'should be an Object, but %v was given.',
         clause,
       );
-    if ('eq' in clause) return this.compare(clause.eq, value) === 0;
-    if ('neq' in clause) return this.compare(clause.neq, value) !== 0;
+    if ('eq' in clause) return this.compare(clause.eq, value, true) === 0;
+    if ('neq' in clause) return this.compare(clause.neq, value, true) !== 0;
   }
 
   /**
@@ -213,7 +218,9 @@ export class OperatorClauseTool extends Service {
         );
       }
       for (let i = 0; i < clause.inq.length; i++) {
-        if (clause.inq[i] == value) return true;
+        if (this.compare(clause.inq[i], value, true) === 0) {
+          return true;
+        }
       }
       return false;
     }
@@ -248,10 +255,9 @@ export class OperatorClauseTool extends Service {
           clause.nin,
         );
       }
-      for (let i = 0; i < clause.nin.length; i++) {
-        if (clause.nin[i] == value) return false;
-      }
-      return true;
+      return clause.nin.every(element => {
+        return this.compare(element, value, true) !== 0;
+      });
     }
   }
 

+ 145 - 43
src/filter/operator-clause-tool.spec.js

@@ -7,42 +7,119 @@ const S = new OperatorClauseTool();
 
 describe('OperatorClauseTool', function () {
   describe('compare', function () {
-    it('returns a negative number if a second value is greatest', function () {
-      expect(S.compare(0, 5)).to.be.eq(-5);
-      expect(S.compare(0, '5')).to.be.eq(-5);
-      expect(S.compare(0, true)).to.be.eq(-1);
-      expect(S.compare('0', 5)).to.be.eq(-5);
-      expect(S.compare('a', 'b')).to.be.eq(-1);
-    });
-
-    it('returns a positive number if a second value is lowest', function () {
-      expect(S.compare(5, 0)).to.be.eq(5);
-      expect(S.compare(5, '0')).to.be.eq(5);
-      expect(S.compare(5, false)).to.be.eq(5);
-      expect(S.compare(5, true)).to.be.eq(4);
-      expect(S.compare('5', 0)).to.be.eq(5);
-      expect(S.compare('b', 'a')).to.be.eq(1);
-    });
-
-    it('returns zero if given values are equal', function () {
+    it('returns zero for equal values of the same type', function () {
       const obj = {};
+      const arr = [];
       expect(S.compare(0, 0)).to.be.eq(0);
-      expect(S.compare(0, '0')).to.be.eq(0);
-      expect(S.compare('0', 0)).to.be.eq(0);
       expect(S.compare('a', 'a')).to.be.eq(0);
+      expect(S.compare(true, true)).to.be.eq(0);
       expect(S.compare(obj, obj)).to.be.eq(0);
+      expect(S.compare(arr, arr)).to.be.eq(0);
       expect(S.compare(null, null)).to.be.eq(0);
       expect(S.compare(undefined, undefined)).to.be.eq(0);
     });
 
-    it('returns NaN if we do not know how to compare', function () {
-      expect(isNaN(S.compare(null, 'string'))).to.be.true;
-      expect(isNaN(S.compare(null, 10))).to.be.true;
-      expect(isNaN(S.compare([], 0))).to.be.true;
-      expect(isNaN(S.compare([], []))).to.be.true;
-      expect(isNaN(S.compare({}, {}))).to.be.true;
-      expect(isNaN(S.compare(10, {}))).to.be.true;
-      expect(isNaN(S.compare('string', Symbol()))).to.be.true;
+    it('returns a non-zero number for different numbers', function () {
+      expect(S.compare(5, 0)).to.be.eq(5);
+      expect(S.compare(0, 5)).to.be.eq(-5);
+    });
+
+    it('returns a non-zero number for different booleans', function () {
+      expect(S.compare(true, false)).to.be.eq(1);
+      expect(S.compare(false, true)).to.be.eq(-1);
+    });
+
+    it('returns a non-zero number for different strings', function () {
+      expect(S.compare('c', 'a')).to.be.eq(1);
+      expect(S.compare('b', 'a')).to.be.eq(1);
+      expect(S.compare('a', 'b')).to.be.eq(-1);
+      expect(S.compare('a', 'c')).to.be.eq(-1);
+    });
+
+    it('compares numbers and numeric strings as numbers', function () {
+      expect(S.compare(10, '10')).to.be.eq(0);
+      expect(S.compare('10', 10)).to.be.eq(0);
+      expect(S.compare(15, '10')).to.be.eq(5);
+      expect(S.compare('15', 10)).to.be.eq(5);
+      expect(S.compare(5, '10')).to.be.eq(-5);
+      expect(S.compare('5', 10)).to.be.eq(-5);
+    });
+
+    it('returns NaN when comparing null/undefined with other types', function () {
+      expect(S.compare(null, 'string')).to.be.NaN;
+      expect(S.compare(null, 10)).to.be.NaN;
+      expect(S.compare(null, 0)).to.be.NaN;
+      expect(S.compare(null, false)).to.be.NaN;
+      expect(S.compare(undefined, 'string')).to.be.NaN;
+      expect(S.compare(undefined, 10)).to.be.NaN;
+      expect(S.compare(undefined, 0)).to.be.NaN;
+      expect(S.compare(undefined, false)).to.be.NaN;
+    });
+
+    it('returns 0 for deeply equal objects', function () {
+      expect(S.compare({a: 1, b: {c: 2}}, {a: 1, b: {c: 2}})).to.be.eq(0);
+    });
+
+    it('returns NaN for different objects', function () {
+      expect(S.compare({a: 1}, {a: 2})).to.be.NaN;
+      expect(S.compare({a: 1}, {b: 1})).to.be.NaN;
+    });
+
+    it('returns 0 for deeply equal arrays', function () {
+      expect(S.compare([1, {a: 2}], [1, {a: 2}])).to.be.eq(0);
+    });
+
+    it('returns NaN for different arrays', function () {
+      expect(S.compare([1, 2], [1, 3])).to.be.NaN;
+      expect(S.compare([1, 2], [1, 2, 3])).to.be.NaN;
+    });
+
+    it('returns NaN for incomparable types', function () {
+      // строка (не число) и число
+      expect(S.compare('abc', 10)).to.be.NaN;
+      expect(S.compare(10, 'abc')).to.be.NaN;
+      // строка и булево
+      expect(S.compare('true', true)).to.be.NaN;
+      expect(S.compare(true, 'true')).to.be.NaN;
+      // число и объект
+      expect(S.compare(10, {})).to.be.NaN;
+      expect(S.compare({}, 10)).to.be.NaN;
+      // строка и символ
+      expect(S.compare('string', Symbol())).to.be.NaN;
+      // объект и массив
+      expect(S.compare({}, [])).to.be.NaN;
+    });
+
+    describe('with type conversion disabled (noTypeConversion = true)', function () {
+      it('returns 0 only for strictly equal primitives', function () {
+        expect(S.compare(10, 10, true)).to.be.eq(0);
+        expect(S.compare('a', 'a', true)).to.be.eq(0);
+        expect(S.compare(true, true, true)).to.be.eq(0);
+      });
+
+      it('returns NaN for different types, even if their values are equivalent', function () {
+        expect(S.compare(10, '10', true)).to.be.NaN;
+        expect(S.compare('10', 10, true)).to.be.NaN;
+        expect(S.compare(1, true, true)).to.be.NaN;
+        expect(S.compare(true, 1, true)).to.be.NaN;
+        expect(S.compare(0, false, true)).to.be.NaN;
+      });
+
+      it('returns a non-zero number for different values of the same type', function () {
+        expect(S.compare(10, 5, true)).to.be.greaterThan(0);
+        expect(S.compare('b', 'a', true)).to.be.greaterThan(0);
+        expect(S.compare(true, false, true)).to.be.greaterThan(0);
+      });
+
+      it('returns 0 for deeply equal objects and NaN for different ones', function () {
+        expect(S.compare({a: 1}, {a: 1}, true)).to.be.eq(0);
+        expect(S.compare({a: 1}, {a: 2}, true)).to.be.NaN;
+      });
+
+      it('returns 0 for deeply equal arrays and NaN for different ones', function () {
+        expect(S.compare([1, 2], [1, 2], true)).to.be.eq(0);
+        expect(S.compare([1, 2], [1, 3], true)).to.be.NaN;
+      });
     });
   });
 
@@ -152,9 +229,7 @@ describe('OperatorClauseTool', function () {
     describe('eq', function () {
       it('returns true if a given value is equal to reference', function () {
         expect(S.testEqNeq({eq: 0}, 0)).to.be.true;
-        expect(S.testEqNeq({eq: 0}, '0')).to.be.true;
-        expect(S.testEqNeq({eq: 0}, false)).to.be.true;
-        expect(S.testEqNeq({eq: 1}, true)).to.be.true;
+        expect(S.testEqNeq({eq: 1}, 1)).to.be.true;
         expect(S.testEqNeq({eq: 'a'}, 'a')).to.be.true;
         expect(S.testEqNeq({eq: true}, true)).to.be.true;
         expect(S.testEqNeq({eq: false}, false)).to.be.true;
@@ -164,6 +239,8 @@ describe('OperatorClauseTool', function () {
       });
 
       it('returns false if a given value is not-equal to reference', function () {
+        expect(S.testEqNeq({eq: 0}, '0')).to.be.false;
+        expect(S.testEqNeq({eq: 0}, false)).to.be.false;
         expect(S.testEqNeq({eq: 0}, 1)).to.be.false;
         expect(S.testEqNeq({eq: 0}, '1')).to.be.false;
         expect(S.testEqNeq({eq: 0}, true)).to.be.false;
@@ -175,6 +252,18 @@ describe('OperatorClauseTool', function () {
         expect(S.testEqNeq({eq: '0'}, Infinity)).to.be.false;
         expect(S.testEqNeq({eq: '0'}, null)).to.be.false;
         expect(S.testEqNeq({eq: '0'}, undefined)).to.be.false;
+        expect(S.testEqNeq({eq: 1}, '0')).to.be.false;
+        expect(S.testEqNeq({eq: 1}, false)).to.be.false;
+        expect(S.testEqNeq({eq: 1}, '1')).to.be.false;
+        expect(S.testEqNeq({eq: 1}, true)).to.be.false;
+        expect(S.testEqNeq({eq: 1}, Infinity)).to.be.false;
+        expect(S.testEqNeq({eq: 1}, null)).to.be.false;
+        expect(S.testEqNeq({eq: 1}, undefined)).to.be.false;
+        expect(S.testEqNeq({eq: '1'}, '0')).to.be.false;
+        expect(S.testEqNeq({eq: '1'}, true)).to.be.false;
+        expect(S.testEqNeq({eq: '1'}, Infinity)).to.be.false;
+        expect(S.testEqNeq({eq: '1'}, null)).to.be.false;
+        expect(S.testEqNeq({eq: '1'}, undefined)).to.be.false;
         expect(S.testEqNeq({eq: true}, false)).to.be.false;
         expect(S.testEqNeq({eq: true}, null)).to.be.false;
         expect(S.testEqNeq({eq: true}, undefined)).to.be.false;
@@ -185,11 +274,9 @@ describe('OperatorClauseTool', function () {
     });
 
     describe('neq', function () {
-      it('returns false if a given value is strictly equal to reference', function () {
+      it('returns false if a given value is equal to reference', function () {
         expect(S.testEqNeq({neq: 0}, 0)).to.be.false;
-        expect(S.testEqNeq({neq: 0}, '0')).to.be.false;
-        expect(S.testEqNeq({neq: 0}, false)).to.be.false;
-        expect(S.testEqNeq({neq: 1}, true)).to.be.false;
+        expect(S.testEqNeq({neq: 1}, 1)).to.be.false;
         expect(S.testEqNeq({neq: 'a'}, 'a')).to.be.false;
         expect(S.testEqNeq({neq: true}, true)).to.be.false;
         expect(S.testEqNeq({neq: false}, false)).to.be.false;
@@ -198,7 +285,9 @@ describe('OperatorClauseTool', function () {
         expect(S.testEqNeq({neq: undefined}, undefined)).to.be.false;
       });
 
-      it('returns true if a given value is strictly not-equal to reference', function () {
+      it('returns true if a given value is not-equal to reference', function () {
+        expect(S.testEqNeq({neq: 0}, '0')).to.be.true;
+        expect(S.testEqNeq({neq: 0}, false)).to.be.true;
         expect(S.testEqNeq({neq: 0}, 1)).to.be.true;
         expect(S.testEqNeq({neq: 0}, '1')).to.be.true;
         expect(S.testEqNeq({neq: 0}, true)).to.be.true;
@@ -210,6 +299,18 @@ describe('OperatorClauseTool', function () {
         expect(S.testEqNeq({neq: '0'}, Infinity)).to.be.true;
         expect(S.testEqNeq({neq: '0'}, null)).to.be.true;
         expect(S.testEqNeq({neq: '0'}, undefined)).to.be.true;
+        expect(S.testEqNeq({neq: 1}, '0')).to.be.true;
+        expect(S.testEqNeq({neq: 1}, false)).to.be.true;
+        expect(S.testEqNeq({neq: 1}, '1')).to.be.true;
+        expect(S.testEqNeq({neq: 1}, true)).to.be.true;
+        expect(S.testEqNeq({neq: 1}, Infinity)).to.be.true;
+        expect(S.testEqNeq({neq: 1}, null)).to.be.true;
+        expect(S.testEqNeq({neq: 1}, undefined)).to.be.true;
+        expect(S.testEqNeq({neq: '1'}, '0')).to.be.true;
+        expect(S.testEqNeq({neq: '1'}, true)).to.be.true;
+        expect(S.testEqNeq({neq: '1'}, Infinity)).to.be.true;
+        expect(S.testEqNeq({neq: '1'}, null)).to.be.true;
+        expect(S.testEqNeq({neq: '1'}, undefined)).to.be.true;
         expect(S.testEqNeq({neq: true}, false)).to.be.true;
         expect(S.testEqNeq({neq: true}, null)).to.be.true;
         expect(S.testEqNeq({neq: true}, undefined)).to.be.true;
@@ -387,18 +488,18 @@ describe('OperatorClauseTool', function () {
 
     it('returns true if a given value has in array', function () {
       expect(S.testInq({inq: [1, 2]}, 2)).to.be.true;
-      expect(S.testInq({inq: [1, 2]}, '2')).to.be.true;
       expect(S.testInq({inq: ['a', 'b']}, 'b')).to.be.true;
-      expect(S.testInq({inq: [1, 2]}, true)).to.be.true;
-      expect(S.testInq({inq: [-1, 0]}, false)).to.be.true;
     });
 
     it('returns false if a given value is not in array', function () {
       expect(S.testInq({inq: [1, 2]}, 3)).to.be.false;
+      expect(S.testInq({inq: [1, 2]}, '2')).to.be.false;
       expect(S.testInq({inq: [1, 2]}, '3')).to.be.false;
       expect(S.testInq({inq: ['a', 'b']}, 'c')).to.be.false;
-      expect(S.testInq({inq: [-1, 0]}, true)).to.be.false;
+      expect(S.testInq({inq: [1, 2]}, true)).to.be.false;
       expect(S.testInq({inq: [1, 2]}, false)).to.be.false;
+      expect(S.testInq({inq: [-1, 0]}, true)).to.be.false;
+      expect(S.testInq({inq: [-1, 0]}, false)).to.be.false;
     });
 
     it('throws an error if a first argument is not an object', function () {
@@ -442,17 +543,18 @@ describe('OperatorClauseTool', function () {
 
     it('returns false if a given value has in array', function () {
       expect(S.testNin({nin: [1, 2]}, 2)).to.be.false;
-      expect(S.testNin({nin: [1, 2]}, '2')).to.be.false;
+
       expect(S.testNin({nin: ['a', 'b']}, 'b')).to.be.false;
-      expect(S.testNin({nin: [1, 2]}, true)).to.be.false;
-      expect(S.testNin({nin: [-1, 0]}, false)).to.be.false;
     });
 
     it('returns true if a given value is not in array', function () {
       expect(S.testNin({nin: [1, 2]}, 3)).to.be.true;
+      expect(S.testNin({nin: [1, 2]}, '2')).to.be.true;
       expect(S.testNin({nin: [1, 2]}, '3')).to.be.true;
       expect(S.testNin({nin: ['a', 'b']}, 'c')).to.be.true;
       expect(S.testNin({nin: [-1, 0]}, true)).to.be.true;
+      expect(S.testNin({nin: [-1, 0]}, false)).to.be.true;
+      expect(S.testNin({nin: [1, 2]}, true)).to.be.true;
       expect(S.testNin({nin: [1, 2]}, false)).to.be.true;
     });
 

+ 39 - 41
src/filter/where-clause-tool.js

@@ -1,7 +1,7 @@
 import {Service} from '@e22m4u/js-service';
-import {getValueByPath} from '../utils/index.js';
 import {InvalidArgumentError} from '../errors/index.js';
 import {OperatorClauseTool} from './operator-clause-tool.js';
+import {getValueByPath, isDeepEqual, isPureObject} from '../utils/index.js';
 
 /**
  * Where clause tool.
@@ -75,8 +75,9 @@ export class WhereClauseTool extends Service {
           const andClause = whereClause[key];
           if (Array.isArray(andClause))
             return andClause.every(clause => this._createFilter(clause)(data));
-          // OrClause (recursion)
-        } else if (key === 'or' && key in whereClause) {
+        }
+        // OrClause (recursion)
+        else if (key === 'or' && key in whereClause) {
           const orClause = whereClause[key];
           if (Array.isArray(orClause))
             return orClause.some(clause => this._createFilter(clause)(data));
@@ -84,34 +85,6 @@ export class WhereClauseTool extends Service {
         // PropertiesClause (properties)
         const value = getValueByPath(data, key);
         const matcher = whereClause[key];
-        // Property value is an array.
-        if (Array.isArray(value)) {
-          // {neq: ...}
-          if (
-            typeof matcher === 'object' &&
-            matcher !== null &&
-            'neq' in matcher &&
-            matcher.neq !== undefined
-          ) {
-            // The following condition is for the case where
-            // we are querying with a neq filter, and when
-            // the value is an empty array ([]).
-            if (value.length === 0) return true;
-            // The neq operator requires each element
-            // of the array to be excluded.
-            return value.every((el, index) => {
-              const where = {};
-              where[index] = matcher;
-              return this._createFilter(where)({...value});
-            });
-          }
-          // Requires one of an array elements to be match.
-          return value.some((el, index) => {
-            const where = {};
-            where[index] = matcher;
-            return this._createFilter(where)({...value});
-          });
-        }
         // Test property value.
         if (this._test(matcher, value)) return true;
       });
@@ -126,30 +99,55 @@ export class WhereClauseTool extends Service {
    * @returns {boolean}
    */
   _test(example, value) {
-    // Test null.
+    // прямое сравнение
+    if (example === value) {
+      return true;
+    }
+    // условием является null
     if (example === null) {
       return value === null;
     }
-    // Test undefined.
+    // условием является undefined
     if (example === undefined) {
       return value === undefined;
     }
-    // Test RegExp.
-    // noinspection ALL
+    // условием является регулярное выражение
     if (example instanceof RegExp) {
-      if (typeof value === 'string') return !!value.match(example);
+      if (typeof value === 'string') {
+        return example.test(value);
+      }
+      // если значением является массив,
+      // то проверяется каждый элемент
+      if (Array.isArray(value)) {
+        return value.some(el => typeof el === 'string' && example.test(el));
+      }
       return false;
     }
-    // Operator clause.
-    if (typeof example === 'object' && !Array.isArray(example)) {
+    // условием является простой объект
+    if (isPureObject(example)) {
       const operatorsTest = this.getService(OperatorClauseTool).testAll(
         example,
         value,
       );
-      if (operatorsTest !== undefined) return operatorsTest;
+      if (operatorsTest !== undefined) {
+        // особая логика для neq с массивами
+        // {hobbies: {neq: 'yoga'}}
+        //   должно вернуть true для
+        // ['bicycle', 'meditation']
+        if ('neq' in example && Array.isArray(value)) {
+          return !value.some(el => isDeepEqual(el, example.neq));
+        }
+        return operatorsTest;
+      }
+    }
+    // значением является массив
+    if (Array.isArray(value)) {
+      // если один из элементов массива соответствует
+      // поиску, то возвращается true
+      const isElementMatched = value.some(el => isDeepEqual(el, example));
+      if (isElementMatched) return true;
     }
-    // Not strict equality.
-    return example == value;
+    return isDeepEqual(example, value);
   }
 
   /**

+ 119 - 9
src/filter/where-clause-tool.spec.js

@@ -13,6 +13,7 @@ const OBJECTS = [
     hobbies: ['bicycle', 'yoga'],
     nickname: 'Spear',
     birthdate: '2002-04-14',
+    address: {city: 'New York', street: '5th Avenue'},
   },
   {
     id: 2,
@@ -22,6 +23,7 @@ const OBJECTS = [
     hobbies: ['yoga', 'meditation'],
     nickname: 'Flower',
     birthdate: '2002-01-12',
+    address: {city: 'London', street: 'Baker Street'},
   },
   {
     id: 3,
@@ -31,6 +33,7 @@ const OBJECTS = [
     hobbies: [],
     nickname: null,
     birthdate: '2002-03-01',
+    address: {city: 'Paris', street: 'Champs-Élysées'},
   },
   {
     id: 4,
@@ -39,6 +42,18 @@ const OBJECTS = [
     age: 32,
     hobbies: ['bicycle'],
     birthdate: '1991-06-24',
+    // нет nickname
+    address: {city: 'New York', street: 'Wall Street'},
+  },
+  {
+    id: 5,
+    name: 'Peter',
+    surname: 'Jones',
+    age: 45,
+    hobbies: ['fishing'],
+    birthdate: '1978-11-05',
+    // нет nickname
+    address: {city: 'New York', street: '5th Avenue'},
   },
 ];
 
@@ -154,32 +169,36 @@ describe('WhereClauseTool', function () {
 
     it('uses the "neq" operator to match non-equality', function () {
       const result = S.filter(OBJECTS, {name: {neq: 'John'}});
-      expect(result).to.have.length(3);
+      expect(result).to.have.length(4);
       expect(result[0]).to.be.eql(OBJECTS[1]);
       expect(result[1]).to.be.eql(OBJECTS[2]);
       expect(result[2]).to.be.eql(OBJECTS[3]);
+      expect(result[3]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "neq" operator to match an empty array', function () {
       const result = S.filter(OBJECTS, {hobbies: {neq: 'bicycle'}});
-      expect(result).to.have.length(2);
+      expect(result).to.have.length(3);
       expect(result[0]).to.be.eql(OBJECTS[1]);
       expect(result[1]).to.be.eql(OBJECTS[2]);
+      expect(result[2]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "gt" operator to compare values', function () {
       const result = S.filter(OBJECTS, {id: {gt: 2}});
-      expect(result).to.have.length(2);
+      expect(result).to.have.length(3);
       expect(result[0]).to.be.eql(OBJECTS[2]);
       expect(result[1]).to.be.eql(OBJECTS[3]);
+      expect(result[2]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "gte" operator to compare values', function () {
       const result = S.filter(OBJECTS, {id: {gte: 2}});
-      expect(result).to.have.length(3);
+      expect(result).to.have.length(4);
       expect(result[0]).to.be.eql(OBJECTS[1]);
       expect(result[1]).to.be.eql(OBJECTS[2]);
       expect(result[2]).to.be.eql(OBJECTS[3]);
+      expect(result[3]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "lt" operator to compare values', function () {
@@ -206,9 +225,10 @@ describe('WhereClauseTool', function () {
 
     it('uses the "nin" operator to compare values', function () {
       const result = S.filter(OBJECTS, {id: {nin: [2, 3]}});
-      expect(result).to.have.length(2);
+      expect(result).to.have.length(3);
       expect(result[0]).to.be.eql(OBJECTS[0]);
       expect(result[1]).to.be.eql(OBJECTS[3]);
+      expect(result[2]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "between" operator to compare values', function () {
@@ -228,8 +248,9 @@ describe('WhereClauseTool', function () {
 
     it('uses the "exists" operator to check non-existence', function () {
       const result = S.filter(OBJECTS, {nickname: {exists: false}});
-      expect(result).to.have.length(1);
+      expect(result).to.have.length(2);
       expect(result[0]).to.be.eql(OBJECTS[3]);
+      expect(result[1]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "like" operator to match by a substring', function () {
@@ -240,10 +261,11 @@ describe('WhereClauseTool', function () {
 
     it('uses the "nlike" operator to exclude by a substring', function () {
       const result = S.filter(OBJECTS, {name: {nlike: '%liv%'}});
-      expect(result).to.have.length(3);
+      expect(result).to.have.length(4);
       expect(result[0]).to.be.eql(OBJECTS[0]);
       expect(result[1]).to.be.eql(OBJECTS[1]);
       expect(result[2]).to.be.eql(OBJECTS[2]);
+      expect(result[3]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "ilike" operator to case-insensitively matching by a substring', function () {
@@ -254,10 +276,11 @@ describe('WhereClauseTool', function () {
 
     it('uses the "nilike" operator to exclude case-insensitively by a substring', function () {
       const result = S.filter(OBJECTS, {name: {nilike: '%LIV%'}});
-      expect(result).to.have.length(3);
+      expect(result).to.have.length(4);
       expect(result[0]).to.be.eql(OBJECTS[0]);
       expect(result[1]).to.be.eql(OBJECTS[1]);
       expect(result[2]).to.be.eql(OBJECTS[2]);
+      expect(result[3]).to.be.eql(OBJECTS[4]);
     });
 
     it('uses the "regexp" operator to compare values', function () {
@@ -274,8 +297,95 @@ describe('WhereClauseTool', function () {
 
     it('does not use undefined to match a null value', function () {
       const result = S.filter(OBJECTS, {nickname: undefined});
-      expect(result).to.have.length(1);
+      expect(result).to.have.length(2);
       expect(result[0]).to.be.eql(OBJECTS[3]);
+      expect(result[1]).to.be.eql(OBJECTS[4]);
+    });
+
+    describe('advanced matching', function () {
+      it('combines multiple operators for one field using "and"', function () {
+        const result = S.filter(OBJECTS, {
+          and: [{age: {gt: 20}}, {age: {lt: 30}}],
+        });
+        expect(result).to.have.length(3);
+        expect(result.map(o => o.id)).to.eql([1, 2, 3]);
+      });
+
+      it('combines multiple operators for one field implicitly', function () {
+        const result = S.filter(OBJECTS, {age: {gt: 20, lt: 30}});
+        expect(result).to.have.length(3);
+        expect(result.map(o => o.id)).to.eql([1, 2, 3]);
+      });
+
+      it('uses dot notation to query nested objects', function () {
+        const result = S.filter(OBJECTS, {'address.city': 'New York'});
+        expect(result).to.have.length(3);
+        expect(result.map(o => o.id)).to.eql([1, 4, 5]);
+      });
+
+      it('uses dot notation combined with operators', function () {
+        const result = S.filter(OBJECTS, {
+          'address.street': {like: '%Avenue%'},
+        });
+        expect(result).to.have.length(2);
+        expect(result.map(o => o.id)).to.eql([1, 5]);
+      });
+
+      it('matches an object by exact deep equality', function () {
+        const result = S.filter(OBJECTS, {
+          address: {city: 'New York', street: '5th Avenue'},
+        });
+        expect(result).to.have.length(2);
+        expect(result.map(o => o.id)).to.eql([1, 5]);
+      });
+
+      it('does not match an object if it has extra properties', function () {
+        const result = S.filter(OBJECTS, {
+          address: {city: 'New York'},
+        });
+        expect(result).to.have.length(0);
+      });
+
+      it('does match an object if property order is different', function () {
+        const result = S.filter(OBJECTS, {
+          address: {street: '5th Avenue', city: 'New York'},
+        });
+        expect(result).to.have.length(2);
+        expect(result[0].id).to.equal(1);
+        expect(result[1].id).to.equal(5);
+      });
+
+      it('matches an array by exact deep equality', function () {
+        const result = S.filter(OBJECTS, {
+          hobbies: ['bicycle', 'yoga'],
+        });
+        expect(result).to.have.length(1);
+        expect(result[0].id).to.equal(1);
+      });
+
+      it('does not match an array if order is different', function () {
+        const result = S.filter(OBJECTS, {
+          hobbies: ['yoga', 'bicycle'],
+        });
+        expect(result).to.have.length(0);
+      });
+
+      it('does not match an array if it contains extra items', function () {
+        const result = S.filter(OBJECTS, {
+          hobbies: ['bicycle'],
+        });
+        // Найдет только объект с id: 4, так как у него hobbies: ['bicycle']
+        expect(result).to.have.length(1);
+        expect(result[0].id).to.equal(4);
+      });
+
+      it('correctly combines multiple operators with dot notation in an "and" clause', function () {
+        const result = S.filter(OBJECTS, {
+          and: [{'address.city': 'New York'}, {age: {gt: 30}}],
+        });
+        expect(result).to.have.length(2);
+        expect(result.map(o => o.id)).to.eql([4, 5]);
+      });
     });
   });