├── .prettierrc ├── .cz.json ├── .commitlintrc.json ├── doc ├── index.md ├── geofencing │ └── index.md ├── advanced │ └── index.md ├── introduction │ └── index.md └── operands │ └── index.md ├── .eslintignore ├── .mocharc.json ├── .releaserc.json ├── codecov.yml ├── .github ├── actions │ ├── eslint │ │ └── action.yml │ ├── install-packages │ │ └── action.yml │ └── unit-tests │ │ └── action.yml ├── issue_template.md ├── workflows │ ├── codeql.yml │ ├── pull_request.workflow.yml │ └── push_branches.workflow.yaml └── pull_request_template.md ├── .npmignore ├── .eslintrc.json ├── .eslintrc-ts.json ├── .gitignore ├── tsconfig.json ├── test ├── keywords │ ├── nothing.test.js │ ├── everything.test.js │ ├── in.test.js │ ├── geospatial.test.js │ ├── ids.test.js │ ├── missing.test.js │ ├── notregexp.test.js │ ├── notmatch.test.js │ ├── notequals.test.js │ ├── match.test.js │ ├── geoPolygon.test.js │ ├── notgeospatial.test.js │ └── notrange.test.js ├── operands │ ├── bool.test.js │ ├── or.test.js │ └── and.test.js ├── geopoint.test.js └── transform │ └── canonical.test.js ├── lib ├── types │ ├── JSONObject.d.ts │ └── KoncordeParseError.ts ├── engine │ ├── matcher │ │ ├── matchNotRange.js │ │ ├── matchEverything.js │ │ ├── matchEquals.js │ │ ├── matchNotRegexp.js │ │ ├── matchMatch.ts │ │ ├── matchNotMatch.ts │ │ ├── matchRegexp.js │ │ ├── matchNotEquals.js │ │ ├── matchGeospatial.js │ │ ├── matchRange.js │ │ ├── matchExists.js │ │ ├── matchNotExists.js │ │ ├── matchNotGeospatial.js │ │ ├── matchSelect.ts │ │ ├── index.js │ │ └── testTables.js │ ├── objects │ │ ├── fieldOperand.js │ │ ├── filter.js │ │ ├── subfilter.js │ │ ├── regexpCondition.js │ │ ├── condition.js │ │ └── rangeCondition.js │ └── index.js ├── transform │ ├── normalizedExists.js │ └── index.js ├── util │ ├── hash.js │ ├── Flatten.ts │ ├── coordinate.js │ ├── convertDistance.js │ ├── geoLocationToCamelCase.js │ ├── stringCompare.js │ ├── ObjectMatcher.ts │ └── convertGeopoint.js ├── README.md └── index.ts ├── package.json ├── benchmark.js ├── README.md └── CHANGELOG.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all" 3 | } -------------------------------------------------------------------------------- /.cz.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } -------------------------------------------------------------------------------- /doc/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | code: false 3 | type: branch 4 | order: 300 5 | title: Real-time API 6 | --- 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.d.ts 2 | lib/index.js 3 | lib/engine/storeOperands.js 4 | lib/types/KoncordeParseError.js 5 | lib/transform/canonical.js 6 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["should-sinon"], 3 | "reporter": "dot", 4 | "recursive": true, 5 | "slow": 2000, 6 | "timeout": 10000 7 | } 8 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["semantic-release-config-kuzzle"], 3 | "branches": [ 4 | "master", 5 | { "name": "beta", "prerelease": true } 6 | ] 7 | } -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "80...100" 3 | 4 | status: 5 | project: 6 | default: 7 | threshold: 1 8 | 9 | patch: 10 | default: 11 | branches: 12 | - master 13 | -------------------------------------------------------------------------------- /.github/actions/eslint/action.yml: -------------------------------------------------------------------------------- 1 | name: ESLint 2 | description: Run ESLint 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - run: npm install 8 | shell: bash 9 | - run: npm run test:lint 10 | shell: bash 11 | -------------------------------------------------------------------------------- /.github/actions/install-packages/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Packages 2 | description: Install necessary packages inside the CI 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - run: sudo apt install libunwind-dev libunwind8 build-essential libstdc++6 -y 8 | shell: bash 9 | -------------------------------------------------------------------------------- /.github/actions/unit-tests/action.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | description: Run Node.js unit tests 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - run: npm install 8 | shell: bash 9 | - run: npm run build 10 | shell: bash 11 | - run: npm run test:unit:coverage 12 | shell: bash 13 | 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | npm-debug.log* 4 | .node_history 5 | .reify-cache 6 | *.log 7 | 8 | # Exclude coverage report 9 | coverage 10 | .nyc_output 11 | 12 | # IDE 13 | .idea 14 | 15 | *# 16 | *~ 17 | .*.swp 18 | .tags 19 | 20 | #sonarqube 21 | .sonar 22 | .scannerwork 23 | .sonarlint 24 | /sonarlint.json 25 | 26 | #Docs for npm package 27 | /doc/ 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["kuzzle"], 3 | "extends": ["plugin:kuzzle/default", "plugin:kuzzle/node"], 4 | "parserOptions": { 5 | "ecmaVersion": 2020 6 | }, 7 | "rules": { 8 | "sort-keys": "off", 9 | "kuzzle/array-foreach": "off" 10 | }, 11 | "overrides": [ 12 | { 13 | "files": ["*.ts"], 14 | "extends": ["plugin:kuzzle/typescript"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc-ts.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "@typescript-eslint/no-explicit-any": 0, 14 | "@typescript-eslint/explicit-module-boundary-types": 0, 15 | "@typescript-eslint/ban-ts-comment": 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | npm-debug.log* 4 | .node_history 5 | .reify-cache 6 | *.log 7 | 8 | # autogenerated files from typescript 9 | *.js.map 10 | *.d.ts 11 | !lib/types/JSONObject.d.ts 12 | lib/index.js 13 | lib/engine/storeOperands.js 14 | lib/transform/canonical.js 15 | lib/types/KoncordeParseError.js 16 | 17 | # Exclude coverage report 18 | coverage 19 | .nyc_output 20 | 21 | # IDE 22 | .idea 23 | 24 | *# 25 | *~ 26 | .*.swp 27 | .tags 28 | 29 | #sonarqube 30 | .sonar 31 | .scannerwork 32 | .sonarlint 33 | /sonarlint.json 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUncheckedIndexedAccess": true, 4 | "declaration": true, 5 | "listEmittedFiles": true, 6 | "module": "commonjs", 7 | "target": "es2020", 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": [ 13 | "node_modules/*" 14 | ] 15 | }, 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true 18 | }, 19 | "rootDir": "lib/", 20 | "include": [ 21 | "lib/**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /test/keywords/nothing.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 6 | const { Koncorde } = require("../../"); 7 | 8 | describe("Koncorde.keyword.nothing", () => { 9 | let koncorde; 10 | let engine; 11 | 12 | beforeEach(() => { 13 | koncorde = new Koncorde(); 14 | engine = koncorde.engines.get(null); 15 | }); 16 | 17 | describe("#storage", () => { 18 | it("should register in the store", () => { 19 | const id = koncorde.register({ nothing: "anything" }); 20 | 21 | const storeEntry = engine.foPairs.get("nothing"); 22 | 23 | should(storeEntry).be.instanceof(FieldOperand); 24 | should(storeEntry.fields.get("all")).eql([ 25 | Array.from(engine.filters.get(id).subfilters)[0], 26 | ]); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | ## Current Behavior 7 | 8 | 9 | ## Possible Solution 10 | 11 | 12 | ## Steps to Reproduce 13 | 14 | 15 | 1. 16 | 2. 17 | 3. 18 | 4. 19 | 20 | ## Context (Environment) 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/types/JSONObject.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | /** 23 | * An interface representing an object with string key and any value 24 | */ 25 | export interface JSONObject { 26 | [key: string]: JSONObject | any; 27 | } 28 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotRange.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const matchRange = require("./matchRange"); 25 | 26 | module.exports = (storage, testTables, document) => 27 | matchRange(storage, testTables, document, true); 28 | -------------------------------------------------------------------------------- /lib/transform/normalizedExists.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | class NormalizedExists { 25 | constructor(path, array, value) { 26 | this.path = path; 27 | this.array = array; 28 | this.value = value; 29 | } 30 | } 31 | 32 | module.exports = NormalizedExists; 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master", "1-dev", "1-stable", "2-dev", "2-stable", "3-dev", "3-stable", "4-dev" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "59 17 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.workflow.yml: -------------------------------------------------------------------------------- 1 | name: Pull request checks 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: ["20", "22", "24"] 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install additional libraries 16 | uses: ./.github/actions/install-packages 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - uses: ./.github/actions/eslint 22 | 23 | unit-tests: 24 | name: Unit Tests - Node.js v${{ matrix.node-version }} 25 | needs: [lint] 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | node-version: ["20", "22", "24"] 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Install additional libraries 34 | uses: ./.github/actions/install-packages 35 | 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | - uses: ./.github/actions/unit-tests 40 | -------------------------------------------------------------------------------- /lib/util/hash.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | const { createHmac } = require("crypto"); 25 | const stringify = require("json-stable-stringify"); 26 | 27 | function hash(seed, input) { 28 | return createHmac("SHA256", seed) 29 | .update(Buffer.from(stringify(input))) 30 | .digest("hex"); 31 | } 32 | 33 | module.exports = { hash }; 34 | -------------------------------------------------------------------------------- /lib/util/Flatten.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from "../types/JSONObject"; 2 | 3 | /** 4 | * Flatten an object transform: 5 | * { 6 | * title: "kuzzle", 7 | * info : { 8 | * tag: "news" 9 | * } 10 | * } 11 | * 12 | * Into an object like: 13 | * { 14 | * title: "kuzzle", 15 | * info.tag: news 16 | * } 17 | * 18 | * @param {Object} target the object we have to flatten 19 | * @returns {Object} the flattened object 20 | */ 21 | export function flattenObject(target: JSONObject): JSONObject { 22 | const output = {}; 23 | 24 | flattenStep(output, target); 25 | 26 | return output; 27 | } 28 | 29 | function flattenStep( 30 | output: JSONObject, 31 | object: JSONObject, 32 | prev: string = null, 33 | ): void { 34 | const keys = Object.keys(object); 35 | 36 | for (let i = 0; i < keys.length; i++) { 37 | const key = keys[i]; 38 | const value = object[key]; 39 | const newKey = prev ? prev + "." + key : key; 40 | 41 | if (Object.prototype.toString.call(value) === "[object Object]") { 42 | output[newKey] = value; 43 | flattenStep(output, value, newKey); 44 | } 45 | 46 | output[newKey] = value; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/push_branches.workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Push checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - beta 8 | 9 | jobs: 10 | release: 11 | name: Release process 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | issues: write 16 | pull-requests: write 17 | packages: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Install additional libraries 23 | uses: ./.github/actions/install-packages 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: "lts/*" 29 | registry-url: "https://registry.npmjs.org" 30 | scope: '@kuzzleio' 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GHP }} 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | SEMANTIC_RELEASE_NPM_PUBLISH: "true" 40 | SEMANTIC_RELEASE_SLACK_WEBHOOK: ${{ secrets.SEMANTIC_RELEASE_SLACK_WEBHOOK }} 41 | run: npx semantic-release 42 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchEverything.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(1) 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | */ 31 | function MatchEverything(operand, testTables) { 32 | testTables.addMatch(operand.fields.get("all")); 33 | } 34 | 35 | module.exports = MatchEverything; 36 | -------------------------------------------------------------------------------- /lib/engine/objects/fieldOperand.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Stores a field-operand pair. 26 | * 27 | * This allows V8 to convert this object to a pure 28 | * C++ class, with direct access to its members, 29 | * instead of a dictionary with b-search access time 30 | * 31 | * @class FieldOperand 32 | * @returns {FieldOperand} 33 | */ 34 | class FieldOperand { 35 | constructor() { 36 | this.fields = new Map(); 37 | this.custom = {}; 38 | } 39 | } 40 | 41 | module.exports = FieldOperand; 42 | -------------------------------------------------------------------------------- /lib/util/coordinate.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | /** 23 | * A simple coordinate (lat, lon) class 24 | * 25 | * This allows V8 to convert this object to a pure 26 | * C++ class, with direct access to its members, 27 | * instead of a dictionary with b-search access time 28 | * 29 | * @class Coordinate 30 | */ 31 | "use strict"; 32 | 33 | class Coordinate { 34 | constructor(lat, lon) { 35 | this.lat = lat; 36 | this.lon = lon; 37 | } 38 | } 39 | 40 | /** 41 | * @type {Coordinate} 42 | */ 43 | module.exports = Coordinate; 44 | -------------------------------------------------------------------------------- /lib/engine/objects/filter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Creates a Filter object referring to a collection of subfilters 26 | * 27 | * @class Filter 28 | * @type {object} 29 | * @property {string} id 30 | * @property {Array>} filters in their canonical form 31 | * @property {Array} subfilters 32 | * 33 | * @param {string} id - filter unique id 34 | * @param {Array>} filters 35 | */ 36 | class Filter { 37 | constructor(id, filters) { 38 | this.id = id; 39 | this.filters = filters; 40 | this.subfilters = new Set(); 41 | } 42 | } 43 | 44 | module.exports = Filter; 45 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | 12 | 13 | ## What does this PR do ? 14 | 15 | 16 | 21 | 22 | ### How should this be manually tested? 23 | 24 | 28 | - Step 1 : 29 | - Step 2 : 30 | - Step 3 : 31 | ... 32 | 33 | ### Other changes 34 | 35 | 39 | 40 | ### Boyscout 41 | 42 | 46 | -------------------------------------------------------------------------------- /lib/engine/objects/subfilter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Creates a Subfilter object referring to a collection of filters and 26 | * conditions 27 | * 28 | * @class Subfilter 29 | * @type {object} 30 | * @property {string} id 31 | * @property {Array} filters 32 | * @property {Array} conditions 33 | * 34 | * @param {string} id - subfilter unique id 35 | * @param {Filter} filter - filter referring to this subfilter 36 | */ 37 | class Subfilter { 38 | constructor(id, filter) { 39 | this.id = id; 40 | this.filters = new Set([filter]); 41 | this.conditions = new Set(); 42 | } 43 | } 44 | 45 | module.exports = Subfilter; 46 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchEquals.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(log n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function MatchEquals(operand, testTables, document) { 33 | for (const [key, value] of operand.fields.entries()) { 34 | const subfilters = value.get(document[key]); 35 | 36 | if (subfilters !== undefined) { 37 | testTables.addMatch(subfilters); 38 | } 39 | } 40 | } 41 | 42 | module.exports = MatchEquals; 43 | -------------------------------------------------------------------------------- /lib/engine/objects/regexpCondition.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const RE2 = require("re2"); 25 | 26 | /** 27 | * Stores a regular expression condition, 28 | * pre-compiling the regular expression in the 29 | * process. 30 | * 31 | * @class RegexpCondition 32 | * @param {string} pattern - regexp pattern 33 | * @param subfilter 34 | * @param {string} [flags] - regexp flags 35 | */ 36 | class RegExpCondition { 37 | constructor(config, pattern, subfilter, flags) { 38 | this.regexp = 39 | config.regExpEngine === "re2" 40 | ? new RE2(pattern, flags) 41 | : new RegExp(pattern, flags); 42 | 43 | this.stringValue = this.regexp.toString(); 44 | this.subfilters = new Set([subfilter]); 45 | } 46 | } 47 | 48 | module.exports = { RegExpCondition }; 49 | -------------------------------------------------------------------------------- /lib/types/KoncordeParseError.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | /** 23 | * Dedicated Error class for filter parsing errors. 24 | * Contains additional fields helping automatizing what went wrong and where 25 | * when complex filters are rejected by Koncorde. 26 | */ 27 | export class KoncordeParseError extends Error { 28 | /** 29 | * The faulty keyword that triggered the error 30 | * @type {string} 31 | */ 32 | keyword: string; 33 | 34 | /** 35 | * The filter path where the error was found 36 | * @type {string} 37 | */ 38 | path: string; 39 | 40 | constructor(message: string, keyword: string, path: string) { 41 | if (path) { 42 | super(`"${path}": ${message}`); 43 | } else { 44 | super(message); 45 | } 46 | 47 | this.keyword = keyword; 48 | this.path = path; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotRegexp.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function MatchNotRegexp(operand, testTables, document) { 33 | for (const [key, field] of operand.fields.entries()) { 34 | for (const entry of field.values()) { 35 | if (document[key] === undefined || !entry.regexp.test(document[key])) { 36 | testTables.addMatch(entry.subfilters); 37 | } 38 | } 39 | } 40 | } 41 | 42 | module.exports = MatchNotRegexp; 43 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchMatch.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | import { matchAny } from "../../util/ObjectMatcher"; 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | export function MatchMatch(operand, testTables, document) { 33 | const filters = operand.custom.filters; 34 | const subfilters = filters 35 | .filter((filterInfo) => matchAny(document, filterInfo.value)) 36 | .map((filterInfo) => filterInfo.subfilter); 37 | 38 | if (subfilters.length > 0) { 39 | testTables.addMatch(subfilters); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotMatch.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | import { matchAny } from "../../util/ObjectMatcher"; 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | export function MatchNotMatch(operand, testTables, document) { 33 | const filters = operand.custom.filters; 34 | const subfilters = filters 35 | .filter((filterInfo) => !matchAny(document, filterInfo.value)) 36 | .map((filterInfo) => filterInfo.subfilter); 37 | 38 | if (subfilters.length > 0) { 39 | testTables.addMatch(subfilters); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchRegexp.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function MatchRegexp(operand, testTables, document) { 33 | for (const [key, field] of operand.fields.entries()) { 34 | const value = document[key]; 35 | 36 | if (typeof value === "string") { 37 | for (const entry of field.values()) { 38 | if (entry.regexp.test(value)) { 39 | testTables.addMatch(entry.subfilters); 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | module.exports = MatchRegexp; 47 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotEquals.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function MatchNotEquals(operand, testTables, document) { 33 | for (const [key, field] of operand.fields.entries()) { 34 | /* 35 | If a key is missing, then we match all registered "not equals" 36 | filters 37 | */ 38 | for (const entry of field) { 39 | if (document[key] !== entry[0]) { 40 | testTables.addMatch(entry[1]); 41 | } 42 | } 43 | } 44 | } 45 | 46 | module.exports = MatchNotEquals; 47 | -------------------------------------------------------------------------------- /lib/util/convertDistance.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | const units = require("node-units"); 25 | 26 | /** 27 | * Converts a distance string value to a number of meters 28 | * @param {string} distance - client-provided distance 29 | * @returns {number} converted distance 30 | */ 31 | function convertDistance(distance) { 32 | // clean up to ensure node-units will be able to convert it 33 | // for instance: "3 258,55 Ft" => "3258.55 ft" 34 | const cleaned = distance 35 | .replace(/[-\s]/g, "") 36 | .replace(/,/g, ".") 37 | .toLowerCase() 38 | .replace(/([0-9])([a-z])/, "$1 $2"); 39 | 40 | let converted; 41 | try { 42 | converted = units.convert(cleaned + " to m"); 43 | } catch (e) { 44 | throw new Error(`unable to parse distance value "${distance}"`); 45 | } 46 | 47 | return converted; 48 | } 49 | 50 | /** 51 | * @type {convertDistance} 52 | */ 53 | module.exports = { convertDistance }; 54 | -------------------------------------------------------------------------------- /lib/util/geoLocationToCamelCase.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | const KEYWORDS = ["lat_lon", "top_left", "bottom_right"]; 25 | 26 | /** 27 | * Converts known geolocation fields from snake_case to camelCase 28 | * Other fields are copied without change 29 | * 30 | * @param {object} obj - object containing geolocation fields 31 | * @returns {object} new object with converted fields 32 | */ 33 | function geoLocationToCamelCase(obj) { 34 | const converted = {}; 35 | const keys = Object.keys(obj); 36 | 37 | for (let i = 0; i < keys.length; i++) { 38 | const k = keys[i]; 39 | const idx = KEYWORDS.indexOf(k); 40 | 41 | if (idx === -1) { 42 | converted[k] = obj[k]; 43 | } else { 44 | converted[ 45 | k 46 | .split("_") 47 | .map((v, j) => 48 | j === 0 ? v : v.charAt(0).toUpperCase() + v.substring(1), 49 | ) 50 | .join("") 51 | ] = obj[k]; 52 | } 53 | } 54 | 55 | return converted; 56 | } 57 | 58 | module.exports = geoLocationToCamelCase; 59 | -------------------------------------------------------------------------------- /lib/engine/objects/condition.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * @typedef Condition 26 | * @type {object} 27 | * @property {string} id 28 | * @property {Array} subfilters 29 | * @property {string} keyword 30 | * @property {object} value 31 | * 32 | * Stores a filter condition. 33 | * 34 | * This allows V8 to convert this object to a pure 35 | * C++ class, with direct access to its members, 36 | * instead of a dictionary with b-search access time 37 | * 38 | * @class Condition 39 | * @param {string} id - condition unique id 40 | * @param {Subfilter} subfilter - subfilter referring to this condition 41 | * @param {string} keyword - corresponding Koncorde keyword 42 | * @param {object} value - condition value 43 | */ 44 | class Condition { 45 | constructor(id, subfilter, keyword, value) { 46 | this.id = id; 47 | this.subfilters = new Set([subfilter]); 48 | this.keyword = keyword; 49 | this.value = value; 50 | } 51 | } 52 | 53 | /** 54 | * @type {Condition} 55 | */ 56 | module.exports = Condition; 57 | -------------------------------------------------------------------------------- /test/keywords/everything.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 5 | const { Koncorde } = require("../../"); 6 | 7 | describe("Koncorde.keyword.everything", () => { 8 | let koncorde; 9 | let engine; 10 | 11 | beforeEach(() => { 12 | koncorde = new Koncorde(); 13 | engine = koncorde.engines.get(null); 14 | }); 15 | 16 | describe("#validation", () => { 17 | it("should validate an empty filter", () => { 18 | should(() => koncorde.validate({})).not.throw(); 19 | }); 20 | 21 | it("should validate a null filter", () => { 22 | should(() => koncorde.validate(null)).not.throw(); 23 | }); 24 | 25 | it("should validate an undefined filter", () => { 26 | should(() => koncorde.validate(null)).not.throw(); 27 | }); 28 | }); 29 | 30 | describe("#storage", () => { 31 | it("should register an empty filter correctly", () => { 32 | const id = koncorde.register({}); 33 | const storeEntry = engine.foPairs.get("everything"); 34 | 35 | should(storeEntry).be.instanceof(FieldOperand); 36 | should(storeEntry.fields).have.value( 37 | "all", 38 | Array.from(engine.filters.get(id).subfilters), 39 | ); 40 | }); 41 | }); 42 | 43 | describe("#matching", () => { 44 | it("should match any document", () => { 45 | const id = koncorde.register({}); 46 | const result = koncorde.test({ foo: "bar" }); 47 | 48 | should(result).be.an.Array().and.not.empty(); 49 | should(result[0]).be.eql(id); 50 | }); 51 | }); 52 | 53 | describe("#removal", () => { 54 | it("should remove the whole f-o pair on delete", () => { 55 | const id = koncorde.register({}); 56 | koncorde.remove(id); 57 | 58 | should(engine.foPairs).and.be.empty(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchGeospatial.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const { convertGeopoint } = require("../../util/convertGeopoint"); 25 | 26 | /** 27 | * Updates the matched filters according to the provided data 28 | * O(log n + m) with n the number of values to be tested against document fields, 29 | * and m the number of matched shapes 30 | * 31 | * @param {FieldOperand} operand - content of all conditions to be tested 32 | * @param {object} testTables - test tables to update when a filter matches the document 33 | * @param {object} document 34 | */ 35 | function MatchGeospatial(operand, testTables, document) { 36 | for (const [key, field] of operand.fields.entries()) { 37 | if (document[key]) { 38 | const point = convertGeopoint(document[key]); 39 | 40 | if (point === null) { 41 | return; 42 | } 43 | 44 | const result = operand.custom.index.queryPoint(point.lat, point.lon); 45 | 46 | for (let j = 0; j < result.length; j++) { 47 | testTables.addMatch(field.get(result[j])); 48 | } 49 | } 50 | } 51 | } 52 | 53 | module.exports = MatchGeospatial; 54 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchRange.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(log n + m) with n the number of range filters stored 27 | * and m the number of matched ranges 28 | * 29 | * @param {FieldOperand} operand - content of all conditions to be tested 30 | * @param {object} testTables - test tables to update when a filter matches the document 31 | * @param {object} document 32 | * @param {boolean} not - used by notrange operator 33 | */ 34 | function MatchRange(operand, testTables, document, not = false) { 35 | for (const [key, field] of operand.fields.entries()) { 36 | let rangeConditions; 37 | 38 | if (typeof document[key] === "number") { 39 | rangeConditions = field.tree.search([document[key], document[key]]); 40 | } else if (not) { 41 | rangeConditions = field.conditions.values(); 42 | } 43 | 44 | if (rangeConditions !== undefined) { 45 | for (const cond of rangeConditions) { 46 | testTables.addMatch(cond.subfilters); 47 | } 48 | } 49 | } 50 | } 51 | 52 | module.exports = MatchRange; 53 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchExists.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(min(n,m)) with n the number of document keys and m the number of fields to test 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function matchExists(operand, testTables, document) { 33 | const keys = Object.keys(document); 34 | 35 | for (let i = 0; i < keys.length; i++) { 36 | const key = keys[i]; 37 | const field = operand.fields.get(key); 38 | 39 | if (field) { 40 | testTables.addMatch(field.subfilters); 41 | 42 | if (Array.isArray(document[key])) { 43 | const uniq = new Set(document[key]); 44 | 45 | for (const value of uniq.values()) { 46 | const entry = field.values.get(value); 47 | 48 | if (entry) { 49 | testTables.addMatch(entry); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | module.exports = matchExists; 58 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotExists.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of document keys 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | function MatchNotExists(operand, testTables, document) { 33 | for (const [key, field] of operand.fields.entries()) { 34 | if (document[key] === undefined) { 35 | testTables.addMatch(field.subfilters); 36 | } 37 | 38 | if (Array.isArray(document[key])) { 39 | for (const entry of field.values) { 40 | if (!document[key].includes(entry[0])) { 41 | testTables.addMatch(entry[1]); 42 | } 43 | } 44 | } else { 45 | // if a field is missing, or not an array, 46 | // then all possible array values are missing too 47 | for (const value of field.values.values()) { 48 | testTables.addMatch(value); 49 | } 50 | } 51 | } 52 | } 53 | 54 | module.exports = MatchNotExists; 55 | -------------------------------------------------------------------------------- /lib/transform/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | const Standardizer = require("./standardize"); 25 | const { Canonical } = require("./canonical"); 26 | 27 | /** 28 | * Checks that provided filters are valid, 29 | * standardizes them by reducing the number of used keywords 30 | * and converts these filters in canonical form 31 | * 32 | * @class Transformer 33 | */ 34 | class Transformer { 35 | constructor(config) { 36 | this.standardizer = new Standardizer(config); 37 | this.canonical = new Canonical(config); 38 | } 39 | 40 | /** 41 | * Checks, standardizes and converts filters in canonical form 42 | * 43 | * @param {Object} filters 44 | * @return {Array} 45 | */ 46 | normalize(filters) { 47 | const standardized = this.standardizer.standardize(filters); 48 | 49 | return this.canonical.convert(standardized); 50 | } 51 | 52 | /** 53 | * Performs a simple filter check to validate it, without converting 54 | * it to canonical form 55 | * 56 | * @param {object} filters 57 | */ 58 | check(filters) { 59 | this.standardizer.standardize(filters); 60 | } 61 | } 62 | 63 | /** 64 | * @type {Transformer} 65 | */ 66 | module.exports = { Transformer }; 67 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchNotGeospatial.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const { convertGeopoint } = require("../../util/convertGeopoint"); 25 | 26 | /** 27 | * Updates the matched filters according to the provided data 28 | * O(log n + m) with n the number of values to be tested against document fields, 29 | * and m the number of matched shapes 30 | * 31 | * @param {FieldOperand} operand - content of all conditions to be tested 32 | * @param {object} testTables - test tables to update when a filter matches the document 33 | * @param {object} document 34 | */ 35 | function MatchNotGeospatial(operand, testTables, document) { 36 | for (const [key, field] of operand.fields.entries()) { 37 | if (document[key]) { 38 | const point = convertGeopoint(document[key]); 39 | 40 | if (point === null) { 41 | return; 42 | } 43 | 44 | const result = operand.custom.index.queryPoint(point.lat, point.lon); 45 | 46 | for (const entry of field) { 47 | if (!result.includes(entry[0])) { 48 | testTables.addMatch(entry[1]); 49 | } 50 | } 51 | } else { 52 | for (const subfilters of field.values()) { 53 | testTables.addMatch(subfilters); 54 | } 55 | } 56 | } 57 | } 58 | 59 | module.exports = MatchNotGeospatial; 60 | -------------------------------------------------------------------------------- /lib/engine/matcher/matchSelect.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | import { flattenObject } from "../../util/Flatten"; 23 | 24 | /** 25 | * Updates the matched filters according to the provided data 26 | * O(n) with n the number of values to be tested against document fields 27 | * 28 | * @param {FieldOperand} operand - content of all conditions to be tested 29 | * @param {object} testTables - test tables to update when a filter matches the document 30 | * @param {object} document 31 | */ 32 | export function MatchSelect(operand, testTables, document) { 33 | for (const [key, indexMap] of operand.fields.entries()) { 34 | if (!Array.isArray(document[key])) { 35 | continue; 36 | } 37 | 38 | for (const [index, indexEngine] of indexMap.entries()) { 39 | // If the index is negative, we need to count from the end of the array 40 | const computedIndex = index >= 0 ? index : document[key].length + index; 41 | 42 | if (computedIndex < 0 || computedIndex >= document[key].length) { 43 | continue; 44 | } 45 | 46 | const value = document[key][computedIndex]; 47 | 48 | const matchedFilters = indexEngine.engine.match(flattenObject({ value })); 49 | 50 | for (const filterId of matchedFilters) { 51 | testTables.addMatch(indexEngine.filters.get(filterId)); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/util/stringCompare.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | /** 23 | * Simple string comparison method, following the same 24 | * behavior than C's strcmp() function. 25 | * Returns 0 if "a" and "b" are equal, a negative value 26 | * if a < b, and a positive one if a > b 27 | * 28 | * This function avoids making 2 comparisons to 29 | * determine if a < b, a == b or a > b with the usual JS 30 | * way of comparing values: 31 | * if (a === b) return 0; 32 | * return a < b ? -1 : 1; 33 | * 34 | * If a and b are random, this method is roughly 3x faster 35 | * than the usual way (~56M ops/s vs ~18M ops/s) 36 | * 37 | * Due to the system of hashes used by V8, though, if 38 | * strings are equals, the usual JS way is much faster 39 | * obviously (5M vs 78M ops/s) 40 | * 41 | * So this method is to be used when strings to compare 42 | * are believed to be random and more often different than equal. 43 | * Array sorting functions are good candidates for this method 44 | * for instance. 45 | * 46 | * @param {string} a 47 | * @param {string} b 48 | * @returns {number} 49 | */ 50 | 51 | "use strict"; 52 | 53 | function strcmp(a, b) { 54 | for (let i = 0; i < Math.min(a.length, b.length); i++) { 55 | const r = a.charCodeAt(i) - b.charCodeAt(i); 56 | 57 | if (r) { 58 | return r; 59 | } 60 | } 61 | 62 | return a.length - b.length; 63 | } 64 | 65 | module.exports = { strcmp }; 66 | -------------------------------------------------------------------------------- /lib/engine/objects/rangeCondition.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Describe either a range or a not-range condition 26 | * 27 | * @class RangeCondition 28 | * @param subfilter 29 | * @param condition 30 | */ 31 | class RangeCondition { 32 | constructor(subfilter, condition) { 33 | this.subfilters = new Set([subfilter]); 34 | this.not = condition.keyword === "notrange"; 35 | 36 | /* 37 | Initializes lower and upper bounds depending on condition arguments 38 | As the interval tree library used only considers inclusive boundaries, 39 | we need to add or substract an epsilon value to provided arguments 40 | for lt and gt options. 41 | */ 42 | this.low = -Infinity; 43 | this.high = Infinity; 44 | 45 | const field = Object.keys(condition.value)[0]; 46 | const args = condition.value[field]; 47 | 48 | for (const key of Object.keys(args)) { 49 | if (key === "gt" || key === "gte") { 50 | this.low = args[key]; 51 | 52 | if (this.not && key === "gte") { 53 | this.low -= 1e-10; 54 | } else if (!this.not && key === "gt") { 55 | this.low += 1e-10; 56 | } 57 | } 58 | 59 | if (key === "lt" || key === "lte") { 60 | this.high = args[key]; 61 | 62 | if (this.not && key === "lte") { 63 | this.high += 1e-10; 64 | } else if (!this.not && key === "lt") { 65 | this.high -= 1e-10; 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | module.exports = { RangeCondition }; 73 | -------------------------------------------------------------------------------- /lib/engine/matcher/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const TestTables = require("./testTables"); 25 | 26 | /** 27 | * Matches documents or messages against stored subscriptions 28 | * 29 | * @class Matcher 30 | * @constructor 31 | */ 32 | class Matcher { 33 | constructor() { 34 | this.matchers = { 35 | equals: require("./matchEquals"), 36 | everything: require("./matchEverything"), 37 | exists: require("./matchExists"), 38 | geospatial: require("./matchGeospatial"), 39 | match: require("./matchMatch").MatchMatch, 40 | notequals: require("./matchNotEquals"), 41 | notexists: require("./matchNotExists"), 42 | notgeospatial: require("./matchNotGeospatial"), 43 | notmatch: require("./matchNotMatch").MatchNotMatch, 44 | notrange: require("./matchNotRange"), 45 | notregexp: require("./matchNotRegexp"), 46 | range: require("./matchRange"), 47 | regexp: require("./matchRegexp"), 48 | select: require("./matchSelect").MatchSelect, 49 | }; 50 | } 51 | 52 | /** 53 | * Matches data against stored subscriptions 54 | * 55 | * @param {Map} foPairs 56 | * @param {Object} data 57 | * @return {Array} 58 | */ 59 | match(foPairs, data) { 60 | const testTables = new TestTables(); 61 | 62 | for (const [key, operand] of foPairs) { 63 | if (this.matchers[key]) { 64 | this.matchers[key](operand, testTables, data); 65 | } 66 | } 67 | 68 | return Object.keys(testTables.matched); 69 | } 70 | } 71 | 72 | module.exports = Matcher; 73 | -------------------------------------------------------------------------------- /lib/engine/matcher/testTables.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | /** 25 | * Duplicates reference test tables and keep track of matching 26 | * subfilters and filters 27 | * 28 | * /!\ Critical section: benchmark performances 29 | * before modifying this object. 30 | * With large number of matching rooms, the "addMatch" method 31 | * takes a large proportion of the document-matching time. 32 | * This might be optimizable by converting this object to a C++ class, 33 | * avoiding the large number of subsequent V8 type testing/casting 34 | * 35 | * @property {Array} matched - matched filters ids 36 | * @property {Uint8Array} conditions - keep track of matched conditions 37 | * @property {Uint8Array} filters - keep track of matched filters 38 | * 39 | * @class TestTables 40 | * @param testTablesRef - test tables reference object 41 | * @param index 42 | * @param collection 43 | */ 44 | class TestTables { 45 | constructor() { 46 | this.matchedConditions = {}; 47 | this.matched = {}; 48 | } 49 | 50 | /** 51 | * Registers a matching subfilters in the test tables 52 | * 53 | * @param {Set} subfilters - matching subfilters 54 | */ 55 | addMatch(subfilters) { 56 | for (const subfilter of subfilters) { 57 | const matched = 58 | this.matchedConditions[subfilter.id] || subfilter.conditions.size; 59 | 60 | if (matched > 1) { 61 | this.matchedConditions[subfilter.id] = matched - 1; 62 | } else { 63 | for (const filter of subfilter.filters) { 64 | this.matched[filter.id] = 1; 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | module.exports = TestTables; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koncorde", 3 | "version": "4.7.0", 4 | "description": "Supersonic reverse matching engine", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "build": "npx tsc", 11 | "clean": "touch lib/index.ts && npm run build | grep TSFILE | cut -d' ' -f 2 | xargs rm", 12 | "prepublishOnly": "npm run build", 13 | "test": "npm run --silent test:lint && npm run test:unit:coverage", 14 | "test:lint": "npm run test:lint:js && npm run test:lint:ts", 15 | "test:lint:js": "eslint --max-warnings=0 ./lib ./test --fix", 16 | "test:lint:ts": "eslint --max-warnings=0 ./lib --ext .ts --config .eslintrc-ts.json --fix", 17 | "test:unit:coverage": "nyc --reporter=text-summary --reporter=lcov mocha", 18 | "test:unit": "DEBUG= npx --node-arg=--trace-warnings mocha --exit", 19 | "codecov": "codecov" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/kuzzleio/koncorde.git" 24 | }, 25 | "keywords": [ 26 | "real-time", 27 | "realtime", 28 | "match", 29 | "matching", 30 | "reverse matching", 31 | "reverse match", 32 | "geofencing" 33 | ], 34 | "author": "Kuzzle", 35 | "license": "Apache-2.0", 36 | "bugs": { 37 | "url": "https://github.com/kuzzleio/koncorde/issues" 38 | }, 39 | "homepage": "https://github.com/kuzzleio/koncorde#readme", 40 | "dependencies": { 41 | "@flatten-js/interval-tree": "2.0.3", 42 | "boost-geospatial-index": "1.4.0", 43 | "json-stable-stringify": "1.3.0", 44 | "kuzzle-espresso-logic-minimizer": "^2.2.0", 45 | "ngeohash": "0.6.3", 46 | "node-units": "0.1.7", 47 | "re2": "1.22.3", 48 | "ts-combinatorics": "1.0.0" 49 | }, 50 | "devDependencies": { 51 | "@types/node": "24.10.1", 52 | "benchmark": "2.1.4", 53 | "codecov": "3.8.3", 54 | "geojson-random": "0.5.0", 55 | "mocha": "11.7.5", 56 | "nyc": "17.1.0", 57 | "random-js": "2.1.0", 58 | "should": "13.2.3", 59 | "should-sinon": "0.0.6", 60 | "eslint-plugin-kuzzle": "0.0.15", 61 | "semantic-release-config-kuzzle": "1.1.2", 62 | "sinon": "21.0.0", 63 | "typescript": "5.4.*" 64 | }, 65 | "engines": { 66 | "node": ">= 12.13.0" 67 | }, 68 | "files": [ 69 | "lib/**/*.js", 70 | "lib/**/*.d.ts", 71 | "lib/**/*.json", 72 | "package.json", 73 | "package-lock.json", 74 | "LICENSE.md", 75 | "README.md" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /test/keywords/in.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const { Koncorde } = require("../../"); 5 | 6 | describe("Koncorde.keyword.in", () => { 7 | let koncorde; 8 | 9 | beforeEach(() => { 10 | koncorde = new Koncorde(); 11 | }); 12 | 13 | describe("#validation", () => { 14 | it("should reject empty filters", () => { 15 | should(() => koncorde.validate({ in: {} })).throw({ 16 | keyword: "in", 17 | message: '"in": expected object to have exactly 1 property, got 0', 18 | path: "in", 19 | }); 20 | }); 21 | 22 | it("should reject filters with multiple defined attributes", () => { 23 | should(() => 24 | koncorde.validate({ in: { bar: ["foo"], foo: ["foo"] } }), 25 | ).throw({ 26 | keyword: "in", 27 | message: '"in": expected object to have exactly 1 property, got 2', 28 | path: "in", 29 | }); 30 | }); 31 | 32 | it("should reject filters with an empty value list", () => { 33 | should(() => koncorde.validate({ in: { foo: [] } })).throw({ 34 | keyword: "in", 35 | message: '"in.foo": cannot be empty', 36 | path: "in.foo", 37 | }); 38 | }); 39 | 40 | it("should reject filters with non-array values attribute", () => { 41 | should(() => koncorde.validate({ in: { foo: "foo" } })).throw({ 42 | keyword: "in", 43 | message: '"in.foo": must be an array', 44 | path: "in.foo", 45 | }); 46 | }); 47 | 48 | it("should reject filters containing a non-string value", () => { 49 | should(() => 50 | koncorde.validate({ in: { foo: ["foo", "bar", 42, "baz"] } }), 51 | ).throw({ 52 | keyword: "in", 53 | message: '"in.foo": must hold only values of type "string"', 54 | path: "in.foo", 55 | }); 56 | }); 57 | 58 | it("should validate a well-formed ids filter", () => { 59 | should(() => 60 | koncorde.validate({ in: { foo: ["foo", "bar", "baz"] } }), 61 | ).not.throw(); 62 | }); 63 | }); 64 | 65 | describe("#standardization", () => { 66 | it('should return a list of "equals" conditions in a "or" operand', () => { 67 | should( 68 | koncorde.transformer.standardizer.standardize({ 69 | in: { foo: ["foo", "bar", "baz"] }, 70 | }), 71 | ).match({ 72 | or: [ 73 | { equals: { foo: "foo" } }, 74 | { equals: { foo: "bar" } }, 75 | { equals: { foo: "baz" } }, 76 | ], 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/keywords/geospatial.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const { Koncorde } = require("../../"); 5 | 6 | /** 7 | * Mutualizes filter removal for all 4 geospatial keywords 8 | */ 9 | describe("Koncorde.keyword.geospatial", () => { 10 | let koncorde; 11 | let engine; 12 | const geoFilter = { 13 | geoBoundingBox: { 14 | foo: { 15 | bottom: 43.5810609, 16 | left: 3.8433703, 17 | top: 43.6331979, 18 | right: 3.9282093, 19 | }, 20 | }, 21 | }; 22 | 23 | beforeEach(() => { 24 | koncorde = new Koncorde(); 25 | engine = koncorde.engines.get(null); 26 | }); 27 | 28 | describe("#removal", () => { 29 | it("should destroy the whole structure when removing the last item", () => { 30 | const id = koncorde.register(geoFilter); 31 | koncorde.remove(id); 32 | should(engine.foPairs).be.empty(); 33 | }); 34 | 35 | it("should remove the entire field if its last condition is removed", () => { 36 | koncorde.register({ 37 | geoDistance: { 38 | bar: { 39 | lat: 13, 40 | lon: 42, 41 | }, 42 | distance: "1km", 43 | }, 44 | }); 45 | 46 | const id = koncorde.register(geoFilter); 47 | 48 | koncorde.remove(id); 49 | 50 | const storage = engine.foPairs.get("geospatial"); 51 | 52 | should(storage.fields.get("bar")).be.an.Object(); 53 | should(storage.fields.get("foo")).be.undefined(); 54 | }); 55 | 56 | it("should remove a single condition from a field if other conditions exist", () => { 57 | const id1 = koncorde.register({ 58 | geoDistance: { 59 | foo: { 60 | lat: 13, 61 | lon: 42, 62 | }, 63 | distance: "1km", 64 | }, 65 | }); 66 | const id2 = koncorde.register(geoFilter); 67 | 68 | const sf = Array.from(engine.filters.get(id1).subfilters)[0]; 69 | const cond = Array.from(sf.conditions)[0].id; 70 | 71 | koncorde.remove(id2); 72 | 73 | const storage = engine.foPairs.get("geospatial"); 74 | 75 | should(storage.fields.get("foo").get(cond)).match(new Set([sf])); 76 | }); 77 | 78 | it("should remove a subfilter from a condition if other subfilters exist", () => { 79 | const id1 = koncorde.register(geoFilter); 80 | const sf = Array.from(engine.filters.get(id1).subfilters)[0]; 81 | const cond = Array.from(sf.conditions)[0].id; 82 | 83 | const id2 = koncorde.register({ 84 | and: [geoFilter, { exists: { field: "bar" } }], 85 | }); 86 | 87 | koncorde.remove(id2); 88 | 89 | const storage = engine.foPairs.get("geospatial"); 90 | 91 | should(storage.fields.get("foo").get(cond)).match(new Set([sf])); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /doc/geofencing/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | code: false 3 | type: page 4 | title: Geofencing 5 | description: Geopoint description 6 | order: 400 7 | --- 8 | 9 | # Geofencing 10 | 11 | Geofencing in Koncorde consists in defining forms in the geo-space using geopoints and geodistances within terms like 12 | [geoBoundingBox](/core/1/guides/cookbooks/realtime-api/terms#geoboundingbox), [geoDistance](/core/1/guides/cookbooks/realtime-api/terms#geodistance), [geoDistanceRange](/core/1/guides/cookbooks/realtime-api/terms#geodistancerange) and [geoPolygon](/core/1/guides/cookbooks/realtime-api/terms#geopolygon). In this section, you will find a detailed 13 | explanation about how to specify geopoints and geodistances. 14 | 15 | ## Geopoints 16 | 17 | A geopoint holds the coordinates of a geographical point expressed as latitude and longitude. 18 | 19 | In Koncorde, geopoints can be defined in multiple ways. All of the following examples are equivalent, and point to the same coordinates with latitude `43.6021299` and longitude `3.8989713`: 20 | 21 | - `[ 43.6021299, 3.8989713 ]` 22 | - `"43.6021299, 3.8989713"` 23 | - `"spfb09x0ud5s"` ([geohash](https://en.wikipedia.org/wiki/Geohash)) 24 | - `{ lat: 43.6021299, lon: 3.8989713 }` 25 | 26 | Alternative 1: 27 | 28 | - `{ latLon: [ 43.6021299, 3.8989713 ] }` 29 | - `{ latLon: { lat: 43.6021299, lon: 3.8989713 } }` 30 | - `{ latLon: "43.6021299, 3.8989713" }` 31 | - `{ latLon: "spfb09x0ud5s"}` ([geohash](https://en.wikipedia.org/wiki/Geohash)) 32 | 33 | Alternative 2: 34 | 35 | - `{ lat_lon: [ 43.6021299, 3.8989713 ] }` 36 | - `{ lat_lon: { lat: 43.6021299, lon: 3.8989713 } }` 37 | - `{ lat_lon: "43.6021299, 3.8989713" }` 38 | - `{ lat_lon: "spfb09x0ud5s"}` ([geohash](https://en.wikipedia.org/wiki/Geohash)) 39 | 40 | ## Geodistances 41 | 42 | Distances used in geofencing filters such as [geoDistance](/core/1/guides/cookbooks/realtime-api/terms/#geodistance/) or [geoDistanceRange](/core/1/guides/cookbooks/realtime-api/terms#geodistance-range) can be expressed in various ways. 43 | 44 | Accepted units: 45 | 46 | - `m`, `meter`, `meters` 47 | - `ft`, `feet`, `feets` 48 | - `in`, `inch`, `inches` 49 | - `yd`, `yard`, `yards` 50 | - `mi`, `mile`, `miles` 51 | 52 | **Note:** if no unit is specified, then Koncorde will express the geodistance in meters. 53 | 54 | Accepted unit modifiers: from `yocto-` (10e-21) to `yotta-` (10e24), and their corresponding short forms (e.g. `kilometers` or `km`) 55 | 56 | Accepted formats: `[.|,]`. 57 | 58 | ### Example 59 | 60 | The following distances are all equivalent and valid input parameters: 61 | 62 | ``` 63 | 1000 64 | 1000 m 65 | 1km 66 | 3280.839895013123 ft 67 | 3280.839895013123FT 68 | 39370.078740157485 inches 69 | 39370.078740157485 inch 70 | 39370.078740157485 in 71 | 1 093,6132983377079 yd 72 | 0.6213727366498067 miles 73 | ``` 74 | -------------------------------------------------------------------------------- /test/keywords/ids.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const { Koncorde } = require("../../"); 5 | 6 | describe("Koncorde.keyword.ids", () => { 7 | let koncorde; 8 | 9 | beforeEach(() => { 10 | koncorde = new Koncorde(); 11 | }); 12 | 13 | describe("#validation", () => { 14 | it("should reject empty filters", () => { 15 | should(() => koncorde.validate({ ids: {} })).throw({ 16 | keyword: "ids", 17 | message: '"ids": expected object to have exactly 1 property, got 0', 18 | path: "ids", 19 | }); 20 | }); 21 | 22 | it('should reject filters with other fields other than the "values" one', () => { 23 | should(() => koncorde.validate({ ids: { foo: ["foo"] } })).throw({ 24 | keyword: "ids", 25 | message: '"ids": the property "values" is missing', 26 | path: "ids", 27 | }); 28 | }); 29 | 30 | it("should reject filters with multiple defined attributes", () => { 31 | should(() => 32 | koncorde.validate({ ids: { values: ["foo"], foo: ["foo"] } }), 33 | ).throw({ 34 | keyword: "ids", 35 | message: '"ids": expected object to have exactly 1 property, got 2', 36 | path: "ids", 37 | }); 38 | }); 39 | 40 | it("should reject filters with an empty value list", () => { 41 | should(() => koncorde.validate({ ids: { values: [] } })).throw({ 42 | keyword: "ids", 43 | message: '"ids.values": cannot be empty', 44 | path: "ids.values", 45 | }); 46 | }); 47 | 48 | it("should reject filters with non-array values attribute", () => { 49 | should(() => koncorde.validate({ ids: { values: "foo" } })).throw({ 50 | keyword: "ids", 51 | message: '"ids.values": must be an array', 52 | path: "ids.values", 53 | }); 54 | }); 55 | 56 | it("should reject filters containing a non-string value", () => { 57 | should(() => 58 | koncorde.validate({ ids: { values: ["foo", "bar", 42, "baz"] } }), 59 | ).throw({ 60 | keyword: "ids", 61 | message: '"ids.values": must hold only values of type "string"', 62 | path: "ids.values", 63 | }); 64 | }); 65 | 66 | it("should validate a well-formed ids filter", () => { 67 | should(() => 68 | koncorde.validate({ ids: { values: ["foo", "bar", "baz"] } }), 69 | ).not.throw(); 70 | }); 71 | }); 72 | 73 | describe("#standardization", () => { 74 | it('should return a list of "equals" conditions in a "or" operand', () => { 75 | should( 76 | koncorde.transformer.standardizer.standardize({ 77 | ids: { values: ["foo", "bar", "baz"] }, 78 | }), 79 | ).match({ 80 | or: [ 81 | { equals: { _id: "foo" } }, 82 | { equals: { _id: "bar" } }, 83 | { equals: { _id: "baz" } }, 84 | ], 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # Technical rundown 2 | 3 | ## Vocabulary 4 | 5 | - a "filter" is the complete set of conditions contained in a subscription request. A "filter" is made of "subfilters", linked by OR operands: if 1 subfilter succeeds, the whole filter succeeds 6 | - a "subfilter" is a subset of conditions linked with AND operands: if 1 condition fails, the whole subfilter fails 7 | - a "condition" is an operand applied on a field, with one or multiple values (e.g. `{equals: { field: "value" } }` ) 8 | - a "field-operand" pair is an operand and its associated field (e.g. "field - equals") 9 | 10 | ## Filter registration 11 | 12 | Upon registration, the provided filter is validated and partially rewritten in order to standardize the use of keywords. For instance, `bool` conditions are rewritten using AND/OR/NOT operands, "in" is converted to a succession of "equals" operands linked with OR operands, and so on. 13 | 14 | Once a filter is validated, it is converted to its canonical form, a very close approximation of its [disjunctive normal form](https://en.wikipedia.org/wiki/Disjunctive_normal_form). 15 | This allows separating a filter to a set of subfilters, themselves containing conditions. 16 | 17 | Then comes the most important part of this engine: the way filters are stored and indexed. 18 | 19 | ## Storage and indexation 20 | 21 | The canonicalized filter is split and its parts are stored in different structures: 22 | 23 | - `storage.filters` provides a link between a filter and its associated subfilters 24 | - `storage.subfilters` provides a bidirectional link between a subfilter, its associated filters, and its associated conditions 25 | - `storage.conditions` provides a link between a condition and its associated subfilters. It also contains the condition's value 26 | 27 | Once stored, filters are indexed in the `storage.foPairs` structure, regrouping all conditions associated to a field-operand pair. 28 | It means that, for instance, all "equals" condition on a field "field" are regrouped and stored together. The way these values are stored closely depends on the corresponding operand (for instance, "range" operands use a specific augmented AVL tree, while geospatial operands use a R\* tree) 29 | 30 | ## Matching 31 | 32 | Whenever data is provided to the engine to get the list of matching rooms, the subfilters indexes are duplicated, so that they can be updated without impacting the reference structure. 33 | 34 | Then, for a given index/collection, all registered field-operand pairs are tested. For each subfilter reference matching a condition, the index is updated to decrement its number of conditions. If it reaches 0, its associated filter is added to the list of returned filters ID. 35 | Another index is then updated, in order to ensure that IDs returned are unique. 36 | 37 | The way each field-operand pair performs its match depends closely on the keyword. Matching mechanisms are described in the corresponding `match/match*` files. 38 | 39 | ## Deleting a filter 40 | 41 | When a filter gets deleted, the filters, subfilters, conditions and field-operand structures are cleaned up. 42 | -------------------------------------------------------------------------------- /lib/util/ObjectMatcher.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from "../types/JSONObject"; 2 | 3 | /** 4 | * Verifies that the provided `obj` value matches the provided `toMatch` value 5 | * 6 | * @param obj The value that should match the `toMatch` property 7 | * @param toMatch The value that the `obj` property should match 8 | */ 9 | export function matchAny(obj: any, toMatch: any): boolean { 10 | if (typeof obj !== typeof toMatch) { 11 | return false; 12 | } 13 | 14 | if (typeof obj === "object" && obj !== null && toMatch !== null) { 15 | if (Array.isArray(obj) !== Array.isArray(toMatch)) { 16 | return false; 17 | } 18 | 19 | if (Array.isArray(obj)) { 20 | return matchArray(obj, toMatch); 21 | } 22 | return matchObject(obj, toMatch); 23 | } 24 | return obj === toMatch; 25 | } 26 | 27 | /** 28 | * Verifies that each values of `match` array are contained in `array` array 29 | * 30 | * @param array The array that should contain every values of `match` array 31 | * @param match The array that should be contained in `array` 32 | */ 33 | export function matchArray(array: Array, match: Array): boolean { 34 | if (array.length < match.length) { 35 | return false; 36 | } 37 | 38 | const arrayCopy = []; 39 | for (let i = 0; i < array.length; i++) { 40 | arrayCopy[i] = array[i]; 41 | } 42 | 43 | for (let i = 0; i < match.length; i++) { 44 | const toMatch = match[i]; 45 | let found = false; 46 | for (let j = 0; j < arrayCopy.length; j++) { 47 | if (matchAny(arrayCopy[j], toMatch)) { 48 | // Remove the value from the arrayCopy so we don't match it twice 49 | // and this reduces the number of iterations we need to do over the arrayCopy 50 | arrayCopy.splice(j, 1); 51 | found = true; 52 | break; 53 | } 54 | } 55 | /** 56 | * If there is no value in the array that matches the value we want to match, 57 | * then the array doesn't match 58 | */ 59 | if (!found) { 60 | return false; 61 | } 62 | } 63 | 64 | return true; 65 | } 66 | 67 | /** 68 | * Verifies that each properties of `match` object are contained in `obj` object 69 | * 70 | * @param obj The object that should contain every properties of `match` object 71 | * @param match The object that should be contained in `obj` 72 | */ 73 | export function matchObject(obj: JSONObject, match: JSONObject): boolean { 74 | /** 75 | * Why not using Object.keys()? 76 | * 77 | * Object.keys() forces us to iterate over all properties of the object to list them first, before we can iterate over them. 78 | * 79 | * In this case we might early exit if we find a property that doesn't match, this means 80 | * we might not test every properties. 81 | * So, to reduce the overhead we iterate over the properties of the match object one by one as we test them. 82 | * This is way faster than doing Object.keys() and then iterating over the result when Objects gets bigger. 83 | * On objects with ~20 properties, this is ~2x faster than doing Object.keys(). 84 | */ 85 | for (const key in match) { 86 | if (!matchAny(obj[key], match[key])) { 87 | return false; 88 | } 89 | } 90 | return true; 91 | } 92 | -------------------------------------------------------------------------------- /doc/advanced/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | code: false 3 | type: page 4 | title: Advanced Notions 5 | order: 600 6 | --- 7 | 8 | # Advanced Notions 9 | 10 | ## Filter Equivalence 11 | 12 | Koncorde filter identifiers are generated based on their content in its [disjunctive normal form](https://en.wikipedia.org/wiki/Disjunctive_normal_form), 13 | which guarantees that different **filters that match the same scope will have the same identifier**. 14 | 15 | For example, both these filters will have the same filter identifier: 16 | 17 | ```json 18 | { 19 | "and": [ 20 | { 21 | "not": { 22 | "in": { "some_document_field": ["foo", "bar"] } 23 | } 24 | }, 25 | { "missing": { "field": "another_field" } } 26 | ] 27 | } 28 | ``` 29 | 30 | And: 31 | 32 | ```json 33 | { 34 | "not": { 35 | "or": [ 36 | { 37 | "or": [ 38 | { "equals": { "some_document_field": "foo" } }, 39 | { "equals": { "some_document_field": "bar" } } 40 | ] 41 | }, 42 | { "exists": { "field": "another_field" } } 43 | ] 44 | } 45 | } 46 | ``` 47 | 48 | For more information, please refer to the [Koncorde README](https://www.npmjs.com/package/koncorde#filter-unique-identifier). 49 | 50 | ## Testing Nested Fields 51 | 52 | Examples described in this documentation show how to test for fields at the root of the provided data objects, but it is also possible to add filters on nested properties. 53 | 54 | To do that, instead of giving the name of the property to test, its path must be supplied as follows: `path.to.property` 55 | 56 | ### Example 57 | 58 | Given the following document: 59 | 60 | ```json 61 | { 62 | "name": { 63 | "first": "Grace", 64 | "last": "Hopper" 65 | } 66 | } 67 | ``` 68 | 69 | Here is a filter, testing for equality on the field `last` in the `name` object: 70 | 71 | ```json 72 | { 73 | "equals": { 74 | "name.last": "Hopper" 75 | } 76 | } 77 | ``` 78 | 79 | ## Matching array values 80 | 81 | A few keywords, like [exists](/core/1/guides/cookbooks/realtime-api/terms/#exists) or [missing](/core/1/guides/cookbooks/realtime-api/terms#missing), allow searching for array values. 82 | 83 | These values can be accessed with the following syntax: `[]` 84 | Only one array value per `exists`/`missing` keyword can be searched in this manner. 85 | 86 | Array values must be scalar. Allowed types are `string`, `number`, `boolean` and the `null` value. 87 | 88 | The array value must be provided using the JSON format: 89 | 90 | - Strings: the value must be enclosed in double quotes. Example: `foo["string value"]` 91 | - Numbers, booleans and `null` must be used as is. Examples: `foo[3.14]`, `foo[false]`, `foo[null]` 92 | 93 | Array values can be combined with [nested properties](/core/1/guides/cookbooks/realtime-api/advanced#testing-nested-fields): `nested.array["value"]` 94 | 95 | ### Example 96 | 97 | Given the following document: 98 | 99 | ```json 100 | { 101 | "name": { 102 | "first": "Grace", 103 | "last": "Hopper", 104 | "hobbies": ["compiler", "COBOL"] 105 | } 106 | } 107 | ``` 108 | 109 | Here is a filter, testing whether the value `compiler` is listed in the array `hobbies`: 110 | 111 | ```js 112 | { 113 | "exists": 'name.hobbies["compiler"]' 114 | } 115 | ``` 116 | -------------------------------------------------------------------------------- /test/operands/bool.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const { Koncorde } = require("../../"); 6 | const NormalizedExists = require("../../lib/transform/normalizedExists"); 7 | 8 | describe("Koncorde.operands.bool", () => { 9 | let koncorde; 10 | 11 | beforeEach(() => { 12 | koncorde = new Koncorde(); 13 | }); 14 | 15 | describe("#validation", () => { 16 | it("should reject empty filters", () => { 17 | should(() => koncorde.validate({ bool: {} })).throw({ 18 | keyword: "bool", 19 | message: '"bool": must be a non-empty object', 20 | path: "bool", 21 | }); 22 | }); 23 | 24 | it("should reject filters with unrecognized bool attributes", () => { 25 | const filter = { 26 | bool: { 27 | must: [{ exists: { foo: "bar" } }], 28 | foo: "bar", 29 | }, 30 | }; 31 | 32 | should(() => koncorde.validate(filter)).throw({ 33 | keyword: "bool", 34 | message: 35 | '"bool": "foo" is not an allowed attribute (allowed: must,must_not,should,should_not)', 36 | path: "bool", 37 | }); 38 | }); 39 | }); 40 | 41 | describe("#standardization", () => { 42 | it("should standardize bool attributes with AND/OR/NOT operands", () => { 43 | const bool = { 44 | bool: { 45 | must: [ 46 | { 47 | in: { 48 | firstName: ["Grace", "Ada"], 49 | }, 50 | }, 51 | { 52 | range: { 53 | age: { 54 | gte: 36, 55 | lt: 85, 56 | }, 57 | }, 58 | }, 59 | ], 60 | must_not: [ 61 | { 62 | equals: { 63 | city: "NYC", 64 | }, 65 | }, 66 | ], 67 | should: [ 68 | { 69 | equals: { 70 | hobby: "computer", 71 | }, 72 | }, 73 | { 74 | exists: "lastName", 75 | }, 76 | ], 77 | should_not: [ 78 | { 79 | regexp: { 80 | hobby: { 81 | value: "^.*ball", 82 | flags: "i", 83 | }, 84 | }, 85 | }, 86 | ], 87 | }, 88 | }; 89 | 90 | const result = koncorde.transformer.standardizer.standardize(bool); 91 | should(result).match({ 92 | and: [ 93 | { 94 | or: [ 95 | { equals: { firstName: "Grace" } }, 96 | { equals: { firstName: "Ada" } }, 97 | ], 98 | }, 99 | { 100 | or: [ 101 | { equals: { hobby: "computer" } }, 102 | { exists: new NormalizedExists("lastName", false, null) }, 103 | ], 104 | }, 105 | { 106 | and: [ 107 | { range: { age: { gte: 36, lt: 85 } } }, 108 | { not: { equals: { city: "NYC" } } }, 109 | { not: { regexp: { hobby: { value: "^.*ball", flags: "i" } } } }, 110 | ], 111 | }, 112 | ], 113 | }); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /test/keywords/missing.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const sinon = require("sinon"); 5 | const { Koncorde } = require("../../"); 6 | 7 | describe("Koncorde.keyword.missing", () => { 8 | let koncorde; 9 | 10 | beforeEach(() => { 11 | koncorde = new Koncorde(); 12 | }); 13 | 14 | describe("#standardization", () => { 15 | it('should return a parsed "not exists" condition (from old syntax)', () => { 16 | const spy = sinon.spy(koncorde.transformer.standardizer, "exists"); 17 | 18 | const result = koncorde.transformer.standardizer.standardize({ 19 | missing: { field: "foo" }, 20 | }); 21 | should(spy.called).be.true(); 22 | should(result).match({ not: { exists: { path: "foo", array: false } } }); 23 | }); 24 | 25 | it('should return a parsed "not exists" condition', () => { 26 | const spy = sinon.spy(koncorde.transformer.standardizer, "exists"); 27 | 28 | const result = koncorde.transformer.standardizer.standardize({ 29 | missing: "foo", 30 | }); 31 | should(spy.called).be.true(); 32 | should(result).match({ not: { exists: { path: "foo", array: false } } }); 33 | }); 34 | }); 35 | 36 | describe("#matching", () => { 37 | it("should match a document without the subscribed field", () => { 38 | const id = koncorde.register({ not: { exists: "foo" } }); 39 | 40 | should(koncorde.test({ bar: "qux" })).eql([id]); 41 | }); 42 | 43 | it("should not match if the document contain the searched field", () => { 44 | koncorde.register({ not: { exists: "foo" } }); 45 | 46 | should(koncorde.test({ foo: "bar" })) 47 | .be.an.Array() 48 | .and.empty(); 49 | }); 50 | 51 | it("should match if the document contains an explicitly undefined field", () => { 52 | const id = koncorde.register({ not: { exists: "foo" } }); 53 | 54 | should(koncorde.test({ foo: undefined })).eql([id]); 55 | }); 56 | 57 | it("should match a document with the subscribed nested keyword", () => { 58 | koncorde.register({ not: { exists: "foo.bar.baz" } }); 59 | should(koncorde.test({ foo: { bar: { baz: "qux" } } })) 60 | .be.an.Array() 61 | .and.empty(); 62 | }); 63 | 64 | it("should match if a document has the searched field, but not the searched array value", () => { 65 | const id = koncorde.register({ not: { exists: 'foo.bar["baz"]' } }); 66 | should(koncorde.test({ foo: { bar: ["qux"] } })).eql([id]); 67 | }); 68 | 69 | it("should not match if a document has the searched field, and the searched array value", () => { 70 | koncorde.register({ not: { exists: 'foo.bar["baz"]' } }); 71 | should(koncorde.test({ foo: { bar: ["baz"] } })) 72 | .Array() 73 | .empty(); 74 | }); 75 | 76 | it("should match if a field is entirely missing, while looking for an array value only", () => { 77 | const id = koncorde.register({ missing: 'foo.bar["baz"' }); 78 | should(koncorde.test({ bar: "foo" })).eql([id]); 79 | }); 80 | 81 | it("should match if looking for an array value, and the field is not an array", () => { 82 | const id = koncorde.register({ missing: 'foo.bar["baz"]' }); 83 | should(koncorde.test({ foo: { bar: 42 } })).eql([id]); 84 | }); 85 | 86 | it("should match if there is a type mismatch", () => { 87 | const id = koncorde.register({ not: { exists: 'foo.bar["true"]' } }); 88 | should(koncorde.test({ foo: { bar: [true] } })).eql([id]); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/geopoint.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const Coordinates = require("../lib/util/coordinate"); 6 | const { convertGeopoint: convert } = require("../lib/util/convertGeopoint"); 7 | 8 | describe("#geopoint conversions", () => { 9 | const coords = new Coordinates(43.6021299, 3.8989713); 10 | 11 | it('"lat, lon"', () => { 12 | should(convert("43.6021299, 3.8989713")) 13 | .be.instanceOf(Coordinates) 14 | .and.match(coords); 15 | }); 16 | 17 | it('"geohash"', () => { 18 | const converted = convert("spfb09x0ud5s"); 19 | 20 | should(converted).be.instanceOf(Coordinates); 21 | 22 | should(converted.lat).be.approximately(coords.lat, 10e-6); 23 | should(converted.lon).be.approximately(coords.lon, 10e-6); 24 | }); 25 | 26 | it("[lat, lon]", () => { 27 | should(convert([43.6021299, 3.8989713])) 28 | .be.instanceOf(Coordinates) 29 | .and.match(coords); 30 | }); 31 | 32 | it("{lat: , lon: ", () => { 33 | should(convert({ lat: 43.6021299, lon: 3.8989713 })) 34 | .be.instanceOf(Coordinates) 35 | .and.match(coords); 36 | }); 37 | 38 | it("{latLon: {lat: , lon: }}", () => { 39 | should(convert({ latLon: { lat: 43.6021299, lon: 3.8989713 } })) 40 | .be.instanceOf(Coordinates) 41 | .and.match(coords); 42 | }); 43 | 44 | it("{lat_lon: {lat: , lon: }}", () => { 45 | should(convert({ lat_lon: { lat: 43.6021299, lon: 3.8989713 } })) 46 | .be.instanceOf(Coordinates) 47 | .and.match(coords); 48 | }); 49 | 50 | it("{latLon: {lat: , lon: }}", () => { 51 | should(convert({ latLon: { lat: 43.6021299, lon: 3.8989713 } })) 52 | .be.instanceOf(Coordinates) 53 | .and.match(coords); 54 | }); 55 | 56 | it("{lat_lon: {lat: , lon: }}", () => { 57 | should(convert({ lat_lon: { lat: 43.6021299, lon: 3.8989713 } })) 58 | .be.instanceOf(Coordinates) 59 | .and.match(coords); 60 | }); 61 | 62 | it('{latLon: "lat, lon"}', () => { 63 | should(convert({ latLon: "43.6021299, 3.8989713" })) 64 | .be.instanceOf(Coordinates) 65 | .and.match(coords); 66 | }); 67 | 68 | it('{lat_lon: "lat, lon"}', () => { 69 | should(convert({ lat_lon: "43.6021299, 3.8989713" })) 70 | .be.instanceOf(Coordinates) 71 | .and.match(coords); 72 | }); 73 | 74 | it('{latLon: "geohash"}', () => { 75 | const converted = convert("spfb09x0ud5s"); 76 | 77 | should(converted).be.instanceOf(Coordinates); 78 | 79 | should(converted.lat).be.approximately(coords.lat, 10e-6); 80 | should(converted.lon).be.approximately(coords.lon, 10e-6); 81 | }); 82 | 83 | it('{lat_lon: "geohash"}', () => { 84 | const converted = convert("spfb09x0ud5s"); 85 | 86 | should(converted).be.instanceOf(Coordinates); 87 | 88 | should(converted.lat).be.approximately(coords.lat, 10e-6); 89 | should(converted.lon).be.approximately(coords.lon, 10e-6); 90 | }); 91 | 92 | it("should return null if the provided data cannot be converted", () => { 93 | should(convert(42)).be.null(); 94 | should(convert()).be.null(); 95 | should(convert(null)).be.null(); 96 | 97 | should(convert("abc")).be.null(); 98 | should(convert("spfb09;x0ud5s")).be.null(); 99 | 100 | should(convert([])).be.null(); 101 | should(convert([12.34])).be.null(); 102 | should(convert([12.34, "abc"])).be.null(); 103 | 104 | should(convert({ latLon: [] })).be.null(); 105 | should(convert({ latLon: [12.34] })).be.null(); 106 | should(convert({ latLon: [12.34, "abc"] })).be.null(); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /lib/util/convertGeopoint.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | "use strict"; 23 | 24 | const geohash = require("ngeohash"); 25 | 26 | const Coordinate = require("./coordinate"); 27 | const geoLocationToCamelCase = require("./geoLocationToCamelCase"); 28 | 29 | const GEOHASH_REGEX = /^[0-9a-z]{4,}$/; 30 | 31 | /** 32 | * Converts one of the accepted geopoint format into 33 | * a standardized version 34 | * 35 | * @param {*} point - geopoint field to convert 36 | * @returns {Coordinate} or null if no accepted format is found 37 | */ 38 | function convertGeopoint(point) { 39 | const t = typeof point; 40 | 41 | if (!point || (t !== "string" && t !== "object")) { 42 | return null; 43 | } 44 | 45 | // Format: "lat, lon" or "geohash" 46 | if (t === "string") { 47 | return fromString(point); 48 | } 49 | 50 | // Format: [lat, lon] 51 | if (Array.isArray(point)) { 52 | if (point.length === 2) { 53 | return toCoordinate(point[0], point[1]); 54 | } 55 | 56 | return null; 57 | } 58 | 59 | const camelCased = geoLocationToCamelCase(point); 60 | 61 | // Format: { lat, lon } 62 | if ( 63 | Object.prototype.hasOwnProperty.call(camelCased, "lat") && 64 | Object.prototype.hasOwnProperty.call(camelCased, "lon") 65 | ) { 66 | return toCoordinate(camelCased.lat, camelCased.lon); 67 | } 68 | 69 | if (Object.prototype.hasOwnProperty.call(camelCased, "latLon")) { 70 | // Format: { latLon: [lat, lon] } 71 | if (Array.isArray(camelCased.latLon)) { 72 | if (camelCased.latLon.length === 2) { 73 | return toCoordinate(camelCased.latLon[0], camelCased.latLon[1]); 74 | } 75 | 76 | return null; 77 | } 78 | 79 | // Format: { latLon: { lat, lon } } 80 | if ( 81 | typeof camelCased.latLon === "object" && 82 | Object.prototype.hasOwnProperty.call(camelCased.latLon, "lat") && 83 | Object.prototype.hasOwnProperty.call(camelCased.latLon, "lon") 84 | ) { 85 | return toCoordinate(camelCased.latLon.lat, camelCased.latLon.lon); 86 | } 87 | 88 | if (typeof camelCased.latLon === "string") { 89 | return fromString(camelCased.latLon); 90 | } 91 | } 92 | 93 | return null; 94 | } 95 | 96 | /** 97 | * Converts a geopoint from a string description 98 | * 99 | * @param {string} str 100 | * @return {Coordinate} 101 | */ 102 | function fromString(str) { 103 | const coordinates = str.split(","); 104 | let converted = null; 105 | 106 | // Format: "latitude, longitude" 107 | if (coordinates.length === 2) { 108 | converted = toCoordinate(coordinates[0], coordinates[1]); 109 | } 110 | // Format: "" 111 | else if (GEOHASH_REGEX.test(str)) { 112 | const decoded = geohash.decode(str); 113 | converted = toCoordinate(decoded.latitude, decoded.longitude); 114 | } 115 | 116 | return converted; 117 | } 118 | 119 | function toCoordinate(lat, lon) { 120 | const latN = Number.parseFloat(lat); 121 | const lonN = Number.parseFloat(lon); 122 | 123 | if (isNaN(latN) || isNaN(lonN)) { 124 | return null; 125 | } 126 | 127 | return new Coordinate(latN, lonN); 128 | } 129 | 130 | /** 131 | * @type {convertGeopoint} 132 | */ 133 | module.exports = { convertGeopoint }; 134 | -------------------------------------------------------------------------------- /test/operands/or.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const { Koncorde } = require("../../"); 5 | 6 | describe("Koncorde.operands.or", () => { 7 | let koncorde; 8 | 9 | beforeEach(() => { 10 | koncorde = new Koncorde(); 11 | }); 12 | 13 | describe("#validation", () => { 14 | it("should reject empty filters", () => { 15 | should(() => koncorde.validate({ or: [] })).throw({ 16 | keyword: "or", 17 | message: '"or": cannot be empty', 18 | path: "or", 19 | }); 20 | }); 21 | 22 | it("should reject non-array content", () => { 23 | should(() => koncorde.validate({ or: { foo: "bar" } })).throw({ 24 | keyword: "or", 25 | message: '"or": must be an array', 26 | path: "or", 27 | }); 28 | }); 29 | 30 | it("should reject if one of the content is not an object", () => { 31 | const filter = { 32 | or: [{ equals: { foo: "bar" } }, [{ exists: { field: "foo" } }]], 33 | }; 34 | 35 | should(() => koncorde.validate(filter)).throw({ 36 | keyword: "or", 37 | message: '"or": can only contain non-empty objects', 38 | path: "or", 39 | }); 40 | }); 41 | 42 | it("should reject if one of the content object does not refer to a valid keyword", () => { 43 | const filter = { 44 | or: [{ equals: { foo: "bar" } }, { foo: "bar" }], 45 | }; 46 | 47 | should(() => koncorde.validate(filter)).throw({ 48 | keyword: "foo", 49 | message: '"or.foo": unknown keyword', 50 | path: "or.foo", 51 | }); 52 | }); 53 | 54 | it("should reject if one of the content object is not a well-formed keyword", () => { 55 | const filter = { 56 | or: [{ equals: { foo: "bar" } }, { exists: { foo: "bar" } }], 57 | }; 58 | 59 | should(() => koncorde.validate(filter)).throw({ 60 | keyword: "exists", 61 | message: '"or.exists": the property "field" is missing', 62 | path: "or.exists", 63 | }); 64 | }); 65 | 66 | it('should validate a well-formed "or" operand', () => { 67 | const filters = { 68 | or: [{ equals: { foo: "bar" } }, { exists: { field: "bar" } }], 69 | }; 70 | 71 | should(() => koncorde.validate(filters)).not.throw(); 72 | }); 73 | }); 74 | 75 | describe("#matching", () => { 76 | it("should match a document if at least 1 condition is fulfilled", () => { 77 | const id = koncorde.register({ 78 | or: [ 79 | { equals: { foo: "bar" } }, 80 | { missing: { field: "bar" } }, 81 | { range: { baz: { lt: 42 } } }, 82 | ], 83 | }); 84 | 85 | const result = koncorde.test({ foo: "foo", bar: "baz", baz: 13 }); 86 | 87 | should(result).eql([id]); 88 | }); 89 | 90 | it("should not match if the document misses all conditions", () => { 91 | koncorde.register({ 92 | or: [ 93 | { equals: { foo: "bar" } }, 94 | { missing: { field: "bar" } }, 95 | { range: { baz: { lt: 42 } } }, 96 | ], 97 | }); 98 | 99 | should(koncorde.test({ foo: "foo", bar: "baz", baz: 42 })) 100 | .be.an.Array() 101 | .and.empty(); 102 | }); 103 | }); 104 | 105 | describe("#removal", () => { 106 | it("should destroy all associated keywords to an OR operand", () => { 107 | const id = koncorde.register({ 108 | or: [ 109 | { equals: { foo: "bar" } }, 110 | { missing: { field: "bar" } }, 111 | { range: { baz: { lt: 42 } } }, 112 | ], 113 | }); 114 | 115 | koncorde.register({ exists: { field: "foo" } }); 116 | 117 | koncorde.remove(id); 118 | 119 | const engine = koncorde.engines.get(null); 120 | 121 | should(engine.foPairs.get("exists")).be.an.Object(); 122 | should(engine.foPairs.get("equals")).be.undefined(); 123 | should(engine.foPairs.get("notexists")).be.undefined(); 124 | should(engine.foPairs.get("range")).be.undefined(); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /test/operands/and.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const { Koncorde } = require("../../"); 5 | 6 | describe("koncorde.operands.and", () => { 7 | let koncorde; 8 | 9 | beforeEach(() => { 10 | koncorde = new Koncorde(); 11 | }); 12 | 13 | describe("#validation", () => { 14 | it("should reject empty filters", () => { 15 | should(() => koncorde.validate({ and: [] })).throw({ 16 | keyword: "and", 17 | message: '"and": cannot be empty', 18 | path: "and", 19 | }); 20 | }); 21 | 22 | it("should reject non-array content", () => { 23 | should(() => koncorde.validate({ and: { foo: "bar" } })).throw({ 24 | keyword: "and", 25 | message: '"and": must be an array', 26 | path: "and", 27 | }); 28 | }); 29 | 30 | it("should reject if one of the content is not an object", () => { 31 | const filter = { 32 | and: [{ equals: { foo: "bar" } }, [{ exists: { field: "foo" } }]], 33 | }; 34 | 35 | should(() => koncorde.validate(filter)).throw({ 36 | keyword: "and", 37 | message: '"and": can only contain non-empty objects', 38 | path: "and", 39 | }); 40 | }); 41 | 42 | it("should reject if one of the content object does not refer to a valid keyword", () => { 43 | const filter = { 44 | and: [{ equals: { foo: "bar" } }, { foo: "bar" }], 45 | }; 46 | 47 | should(() => koncorde.validate(filter)).throw({ 48 | keyword: "foo", 49 | message: '"and.foo": unknown keyword', 50 | path: "and.foo", 51 | }); 52 | }); 53 | 54 | it("should reject if one of the content object is not a well-formed keyword", () => { 55 | const filter = { 56 | and: [{ equals: { foo: "bar" } }, { exists: { foo: "bar" } }], 57 | }; 58 | 59 | should(() => koncorde.validate(filter)).throw({ 60 | keyword: "exists", 61 | message: '"and.exists": the property "field" is missing', 62 | path: "and.exists", 63 | }); 64 | }); 65 | 66 | it('should validate a well-formed "and" operand', () => { 67 | const filter = { 68 | and: [{ equals: { foo: "bar" } }, { exists: { field: "bar" } }], 69 | }; 70 | 71 | should(koncorde.validate(filter)).not.throw(); 72 | }); 73 | }); 74 | 75 | describe("#matching", () => { 76 | it("should match a document with multiple AND conditions", () => { 77 | const filters = { 78 | and: [ 79 | { equals: { name: "bar" } }, 80 | { exists: 'skills.languages["javascript"]' }, 81 | ], 82 | }; 83 | 84 | const id = koncorde.register(filters); 85 | const result = koncorde.test({ 86 | name: "bar", 87 | skills: { languages: ["c++", "javascript", "c#"] }, 88 | }); 89 | 90 | should(result).eql([id]); 91 | }); 92 | 93 | it("should not match if the document misses at least 1 condition", () => { 94 | koncorde.register({ 95 | and: [ 96 | { equals: { name: "bar" } }, 97 | { exists: 'skills.languages["javascript"]' }, 98 | ], 99 | }); 100 | 101 | const result = koncorde.test({ 102 | name: "qux", 103 | skills: { languages: ["ruby", "php", "elm", "javascript"] }, 104 | }); 105 | 106 | should(result).be.an.Array().and.empty(); 107 | }); 108 | }); 109 | 110 | describe("#removal", () => { 111 | it("should destroy all associated keywords to an AND operand", () => { 112 | const id = koncorde.register({ 113 | and: [ 114 | { equals: { foo: "bar" } }, 115 | { missing: { field: "bar" } }, 116 | { range: { baz: { lt: 42 } } }, 117 | ], 118 | }); 119 | 120 | koncorde.register({ exists: { field: "foo" } }); 121 | 122 | koncorde.remove(id); 123 | 124 | const engine = koncorde.engines.get(null); 125 | 126 | should(engine.foPairs.get("exists")).be.an.Object(); 127 | should(engine.foPairs.get("equals")).be.undefined(); 128 | should(engine.foPairs.get("notexists")).be.undefined(); 129 | should(engine.foPairs.get("range")).be.undefined(); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const v8 = require('v8'); 4 | 5 | const Benchmark = require('benchmark'); 6 | const georandom = require('geojson-random'); 7 | const { 8 | MersenneTwister19937, 9 | string: randomStringEngine, 10 | integer: randomIntegerEngine 11 | } = require('random-js'); 12 | 13 | const { Koncorde } = require('.'); 14 | 15 | const max = 10000; 16 | const engine = MersenneTwister19937.autoSeed(); 17 | const rgen = { 18 | int: randomIntegerEngine(-10000, 10000), 19 | string: randomStringEngine(), 20 | }; 21 | 22 | let filters = []; 23 | const koncorde = new Koncorde(); 24 | 25 | const matching = document => { 26 | const suite = new Benchmark.Suite(); 27 | 28 | suite 29 | .add('\tMatching', () => { 30 | koncorde.test(document); 31 | }) 32 | .on('cycle', event => { 33 | console.log(String(event.target)); 34 | removeFilters(); 35 | }) 36 | .run({async: false}); 37 | }; 38 | 39 | function removeFilters() { 40 | const removalStart = Date.now(); 41 | 42 | for (const filter of filters) { 43 | koncorde.remove(filter); 44 | } 45 | 46 | filters = []; 47 | console.log(`\tFilters removal: time = ${(Date.now() - removalStart)/1000}s`); 48 | } 49 | 50 | function test (name, generator, document) { 51 | const baseHeap = v8.getHeapStatistics().total_heap_size; 52 | 53 | console.log(`\n> Benchmarking keyword: ${name}`); 54 | const filterStartTime = Date.now(); 55 | 56 | for (let i = 0;i < max; i++) { 57 | // Using the filter name as a collection to isolate 58 | // benchmark calculation per keyword 59 | filters.push(koncorde.register(generator())); 60 | } 61 | 62 | const filterEndTime = (Date.now() - filterStartTime) / 1000; 63 | console.log(`\tIndexation: time = ${filterEndTime}s, mem = +${Math.round((v8.getHeapStatistics().total_heap_size - baseHeap) / 1024 / 1024)}MB`); 64 | 65 | matching(document); 66 | } 67 | 68 | function run () { 69 | test( 70 | 'equals', 71 | () => ({equals: {str: rgen.string(engine, 20)}}), 72 | { str: rgen.string(engine, 20) }); 73 | 74 | test( 75 | 'exists', 76 | () => ({exists: {field: rgen.string(engine, 20)}}), 77 | { [rgen.string(engine, 20)]: true }); 78 | 79 | test('geoBoundingBox', 80 | () => { 81 | const pos = georandom.position(); 82 | 83 | return { 84 | geoBoundingBox: { 85 | point: { 86 | bottom: pos[1] - .5, 87 | left: pos[0], 88 | right: pos[0] + .5, 89 | top: pos[1], 90 | } 91 | } 92 | }; 93 | }, 94 | { point: [0, 0] }); 95 | 96 | test('geoDistance', 97 | () => { 98 | const pos = georandom.position(); 99 | 100 | return { 101 | geoDistance: { 102 | distance: '500m', 103 | point: [pos[1], pos[0]], 104 | } 105 | }; 106 | }, 107 | { point: [0, 0] }); 108 | 109 | test('geoDistanceRange', 110 | () => { 111 | const pos = georandom.position(); 112 | 113 | return { 114 | geoDistanceRange: { 115 | from: '500m', 116 | point: [pos[1], pos[0]], 117 | to: '1km', 118 | } 119 | }; 120 | }, 121 | { point: [0, 0] }); 122 | 123 | test('geoPolygon (5 vertices)', 124 | () => { 125 | const polygon = georandom 126 | .polygon(1, 5) 127 | .features[0] 128 | .geometry.coordinates[0].map(c => [c[1], c[0]]); 129 | 130 | return { 131 | geoPolygon: { 132 | point: { 133 | points: polygon 134 | } 135 | } 136 | }; 137 | }, 138 | { point: [0, 0] }); 139 | 140 | test('in (5 random values)', 141 | () => { 142 | const values = []; 143 | 144 | for(let i = 0; i < 5; i++) { 145 | values.push(rgen.string(engine, 20)); 146 | } 147 | 148 | return {in: {str: values}}; 149 | }, 150 | { str: rgen.string(engine, 20) }); 151 | 152 | test('range (random bounds)', 153 | () => { 154 | const bound = rgen.int(engine); 155 | 156 | return { 157 | range: { 158 | integer: { 159 | gte: bound, 160 | lte: bound + 100 161 | } 162 | } 163 | }; 164 | }, 165 | { integer: rgen.int(engine) }); 166 | } 167 | 168 | console.log(`Filter count per tested keyword: ${max}`); 169 | run(); 170 | -------------------------------------------------------------------------------- /doc/introduction/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | code: false 3 | type: page 4 | title: Introduction 5 | order: 0 6 | description: Introduction to Koncorde 7 | --- 8 | 9 | # Introduction 10 | 11 | [Koncorde](https://www.npmjs.com/package/koncorde) is a data percolation engine 12 | and is part of Kuzzle's real-time engine. It is used to: 13 | 14 | - trigger notifications on [real-time subscriptions](/core/1/guides/essentials/real-time) 15 | - [perform data validation](/core/1/guides/essentials/data-validation) 16 | 17 | Koncorde exposes a [DSL](https://wikipedia.org/en/Domain-specific_language) that enables you to define filters you can apply to any 18 | stream of data and be notified whenever the content of the stream matches the filter. 19 | This paradigm is called "percolation" and is the foundation of Kuzzle's real-time engine. 20 | 21 | In other words, a percolation engine is the inverse of a search engine, where 22 | data is indexed and filters are used to retrieve data that matches them. 23 | 24 | **This is different from document search [read more about how to search persistent data](/core/1/guides/essentials/store-access-data#document-search).** 25 | 26 | A data percolation engine has the following properties: 27 | 28 | - an arbitrary number of filters can be indexed 29 | - whenever data is submitted to the engine, it returns the indexed filters matching it 30 | - data is never stored in the engine 31 | 32 | The DSL that Koncorde exposes is directly inspired by Elasticsearch, so that defining 33 | real-time filters isn't much different than defining search querires. 34 | 35 | One of the great features of Koncorde is that it enables you to filter **geo-localized 36 | data**, for example, by defining a bounding polgon and checking whether the points 37 | contained in your data are contained or not in it. 38 | 39 | If you are looking for information about how to setup a live data subscription 40 | in Kuzzle, please refer to [the specific docs in the Essentials section](/core/1/guides/essentials/real-time). 41 | 42 | ## Quick start 43 | 44 | As mentioned above, Koncorde lets you express "filters" that you can test on 45 | a set of "documents" (represented as POJOs) to check whether the filter matches 46 | or not the contents of the document. So, let's try it out by defining a filter 47 | that matches all the documents that contain a geo-point at less than 500m from 48 | a given center point. 49 | 50 | First, you must install Koncorde in your project (the easiest way is to use NPM) 51 | 52 | ```bash 53 | 54 | npm i koncorde 55 | ``` 56 | 57 | Then, create a `koncorde-demo.js` file and copy-paste the following code inside: 58 | 59 | ```js 60 | const Koncorde = require('koncorde'); 61 | const engine = new Koncorde(); 62 | 63 | const filter = { 64 | geoDistance: { 65 | // This is our center-point 66 | position: { 67 | lat: 43.6073913, 68 | lon: 3.9109057 69 | }, 70 | distance: '500m' 71 | } 72 | }; 73 | 74 | // Register the filter in the Koncorde Engine 75 | // (don't worry about the index/collection parameters for now) 76 | engine.register('index', 'collection', filter).then(result => { 77 | // The filter identifier depends on a random seed (see below) 78 | // For now, let's pretend its value is 5db7052792b18cb2 79 | console.log(`Filter identifier: ${result.id}`); 80 | 81 | // *** Now, let's test data with our engine *** 82 | 83 | // Returns: [] (distance is greater than 500m) 84 | console.log( 85 | engine.test('index', 'collection', { 86 | position: { 87 | lat: 43.6073913, 88 | lon: 5.7 89 | } 90 | }) 91 | ); 92 | 93 | // Returns: ['5db7052792b18cb2'] 94 | console.log( 95 | engine.test('index', 'collection', { 96 | position: { 97 | lat: 43.608, 98 | lon: 3.905 99 | } 100 | }) 101 | ); 102 | 103 | // Returns: [] (the geopoint is not stored in a "position" field) 104 | console.log( 105 | engine.test('index', 'collection', { 106 | point: { 107 | lat: 43.608, 108 | lon: 3.905 109 | } 110 | }) 111 | ); 112 | }); 113 | ``` 114 | 115 | Then, to see Koncorde in action, execute the file 116 | 117 | ```bash 118 | node koncorde-demo.js 119 | ``` 120 | 121 | ## Next steps 122 | 123 | Feel free to play with the `geoDistance` position and radius, 124 | as well as with tested points to see the different results in the previous example. 125 | You can also dive into more complex filters by playing with other [terms](/core/1/guides/cookbooks/realtime-api/terms) and [operands](/core/1/guides/cookbooks/realtime-api/operands). 126 | -------------------------------------------------------------------------------- /doc/operands/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | code: false 3 | type: page 4 | title: Filter Operands 5 | description: Combines multiple terms to create complex filters 6 | order: 300 7 | --- 8 | 9 | # Operands 10 | 11 | Filters in Koncorde are constituted of terms and operands. In this section, you will find an exhaustive listing of all 12 | the available operands. Operands allow you to combine multiple terms together in the same filter. 13 | You can also refer to the [terms](/core/1/guides/cookbooks/realtime-api/terms) reference to know about 14 | all the available terms. 15 | 16 | ::: info 17 | Note that the ability to combine multiple terms together allows to create different filters that have equivalent scope. 18 | Such filters are optimized by Koncorde, thus [internally represented by the same ID](/core/1/guides/cookbooks/realtime-api/advanced#filter-equivalence). 19 | ::: 20 | 21 | ## and 22 | 23 | The `and` filter takes an array of filter objects, combining them with AND operands. 24 | 25 | ### Given the following documents: 26 | 27 | ```js 28 | { 29 | firstName: 'Grace', 30 | lastName: 'Hopper', 31 | city: 'NYC', 32 | hobby: 'computer' 33 | }, 34 | { 35 | firstName: 'Ada', 36 | lastName: 'Lovelace', 37 | city: 'London', 38 | hobby: 'computer' 39 | } 40 | ``` 41 | 42 | ### The following filter validates the first document: 43 | 44 | ```js 45 | { 46 | and: [ 47 | { 48 | equals: { 49 | city: 'NYC' 50 | } 51 | }, 52 | { 53 | equals: { 54 | hobby: 'computer' 55 | } 56 | } 57 | ]; 58 | } 59 | ``` 60 | 61 | ## bool 62 | 63 | Returns documents matching a combination of filters. 64 | 65 | This operand accepts the following attributes: 66 | 67 | - `must` all listed conditions must be `true` 68 | - `must_not` all listed conditions must be `false` 69 | - `should` one of the listed condition must be `true` 70 | - `should_not` one of the listed condition must be `false` 71 | 72 | Each one of these attributes are an array of filter objects. 73 | 74 | ### Given the following documents: 75 | 76 | ```js 77 | { 78 | firstName: 'Grace', 79 | lastName: 'Hopper', 80 | age: 85, 81 | city: 'NYC', 82 | hobby: 'computer' 83 | }, 84 | { 85 | firstName: 'Ada', 86 | lastName: 'Lovelace', 87 | age: 36 88 | city: 'London', 89 | hobby: 'computer' 90 | }, 91 | { 92 | firstName: 'Marie', 93 | lastName: 'Curie', 94 | age: 55, 95 | city: 'Paris', 96 | hobby: 'radium' 97 | } 98 | ``` 99 | 100 | ### The following filter validates the second document: 101 | 102 | ```js 103 | { 104 | bool: { 105 | must : [ 106 | { 107 | in : { 108 | firstName : ['Grace', 'Ada'] 109 | } 110 | }, 111 | { 112 | range: { 113 | age: { 114 | gte: 36, 115 | lt: 85 116 | } 117 | } 118 | } 119 | ], 120 | 'must_not' : [ 121 | { 122 | equals: { 123 | city: 'NYC' 124 | } 125 | } 126 | ], 127 | should : [ 128 | { 129 | equals : { 130 | hobby : 'computer' 131 | } 132 | }, 133 | { 134 | exists : { 135 | field : 'lastName' 136 | } 137 | } 138 | ] 139 | } 140 | } 141 | ``` 142 | 143 | ## not 144 | 145 | The `not` filter omits the matching data. 146 | 147 | ### Given the following documents: 148 | 149 | ```js 150 | { 151 | firstName: 'Grace', 152 | lastName: 'Hopper', 153 | city: 'NYC', 154 | hobby: 'computer' 155 | }, 156 | { 157 | firstName: 'Ada', 158 | lastName: 'Lovelace', 159 | city: 'London', 160 | hobby: 'computer' 161 | } 162 | ``` 163 | 164 | ### The following filter validates the first document: 165 | 166 | ```js 167 | { 168 | not: { 169 | equals: { 170 | city: 'London'; 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | ## or 177 | 178 | The `or` filter takes an array containing filter objects, combining them using OR operands. 179 | 180 | ### Given the following documents: 181 | 182 | ```js 183 | { 184 | firstName: 'Grace', 185 | lastName: 'Hopper', 186 | city: 'NYC', 187 | hobby: 'computer' 188 | }, 189 | { 190 | firstName: 'Ada', 191 | lastName: 'Lovelace', 192 | city: 'London', 193 | hobby: 'computer' 194 | }, 195 | { 196 | firstName: 'Marie', 197 | lastName: 'Curie', 198 | city: 'Paris', 199 | hobby: 'radium' 200 | } 201 | ``` 202 | 203 | ### The following filter validates the first two documents: 204 | 205 | ```js 206 | { 207 | or: [ 208 | { 209 | equals: { 210 | city: 'NYC' 211 | } 212 | }, 213 | { 214 | equals: { 215 | city: 'London' 216 | } 217 | } 218 | ]; 219 | } 220 | ``` 221 | -------------------------------------------------------------------------------- /test/keywords/notregexp.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const sinon = require("sinon"); 5 | 6 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 7 | const { RegExpCondition } = require("../../lib/engine/objects/regexpCondition"); 8 | const { Koncorde } = require("../../"); 9 | 10 | describe("Koncorde.keyword.notregexp", () => { 11 | let koncorde; 12 | let engine; 13 | 14 | beforeEach(() => { 15 | koncorde = new Koncorde(); 16 | engine = koncorde.engines.get(null); 17 | }); 18 | 19 | describe("#storage", () => { 20 | it("should invoke regexp storage function", () => { 21 | const spy = sinon.spy(engine.storeOperand, "regexp"); 22 | 23 | const id = koncorde.register({ 24 | not: { 25 | regexp: { 26 | foo: { 27 | value: "^\\w{2}oba\\w$", 28 | flags: "i", 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | const storage = engine.foPairs.get("notregexp"); 35 | const condition = new RegExpCondition( 36 | { regExpEngine: "re2" }, 37 | "^\\w{2}oba\\w$", 38 | Array.from(engine.filters.get(id).subfilters)[0], 39 | "i", 40 | ); 41 | 42 | should(spy.called).be.true(); 43 | 44 | should(storage).be.instanceOf(FieldOperand); 45 | should(storage.fields.get("foo").get(condition.stringValue)).eql( 46 | condition, 47 | ); 48 | }); 49 | 50 | it("should invoke regexp storage function (js engine)", () => { 51 | koncorde = new Koncorde({ regExpEngine: "js" }); 52 | engine = koncorde.engines.get(null); 53 | 54 | const spy = sinon.spy(engine.storeOperand, "regexp"); 55 | 56 | const id = koncorde.register({ 57 | not: { 58 | regexp: { 59 | foo: { 60 | value: "^\\w{2}oba\\w$", 61 | flags: "i", 62 | }, 63 | }, 64 | }, 65 | }); 66 | 67 | const storage = engine.foPairs.get("notregexp"); 68 | const condition = new RegExpCondition( 69 | { regExpEngine: "js" }, 70 | "^\\w{2}oba\\w$", 71 | Array.from(engine.filters.get(id).subfilters)[0], 72 | "i", 73 | ); 74 | 75 | should(spy.called).be.true(); 76 | 77 | should(storage).be.instanceOf(FieldOperand); 78 | should(storage.fields.get("foo").get(condition.stringValue)).eql( 79 | condition, 80 | ); 81 | }); 82 | }); 83 | 84 | describe("#matching", () => { 85 | it("should not match a document if its registered field matches the regexp", () => { 86 | koncorde.register({ 87 | not: { 88 | regexp: { 89 | foo: { 90 | value: "^\\w{2}oba\\w$", 91 | flags: "i", 92 | }, 93 | }, 94 | }, 95 | }); 96 | 97 | should(koncorde.test({ foo: "foobar" })) 98 | .be.an.Array() 99 | .and.be.empty(); 100 | }); 101 | 102 | it("should match a document if its registered field does not match the regexp", () => { 103 | const id = koncorde.register({ 104 | not: { 105 | regexp: { 106 | foo: { 107 | value: "^\\w{2}oba\\w$", 108 | flags: "i", 109 | }, 110 | }, 111 | }, 112 | }); 113 | 114 | const result = koncorde.test({ foo: "bar" }); 115 | 116 | should(result).eql([id]); 117 | }); 118 | 119 | it("should match if the document does not contain the registered field", () => { 120 | koncorde.register({ 121 | not: { 122 | regexp: { 123 | foo: { 124 | value: "^\\w{2}oba\\w$", 125 | flags: "i", 126 | }, 127 | }, 128 | }, 129 | }); 130 | 131 | should(koncorde.test({ bar: "qux" })) 132 | .be.an.Array() 133 | .and.have.length(1); 134 | }); 135 | 136 | it("should match a document with the subscribed nested keyword", () => { 137 | const id = koncorde.register({ 138 | not: { 139 | regexp: { 140 | "foo.bar.baz": { 141 | value: "^\\w{2}oba\\w$", 142 | flags: "i", 143 | }, 144 | }, 145 | }, 146 | }); 147 | 148 | const result = koncorde.test({ foo: { bar: { baz: "bar" } } }); 149 | 150 | should(result).eql([id]); 151 | }); 152 | }); 153 | 154 | describe("#removal", () => { 155 | it("should invoke regexp removal function", () => { 156 | const spy = sinon.spy(engine.removeOperand, "regexp"); 157 | 158 | const id = koncorde.register({ 159 | not: { 160 | regexp: { 161 | foo: { 162 | value: "^\\w{2}oba\\w$", 163 | flags: "i", 164 | }, 165 | }, 166 | }, 167 | }); 168 | 169 | koncorde.remove(id); 170 | 171 | should(spy.called).be.true(); 172 | should(engine.foPairs).be.empty(); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Codecov](http://codecov.io/github/kuzzleio/koncorde/coverage.svg?branch=master)](http://codecov.io/github/kuzzleio/koncorde?branch=master) 2 | [![Code Quality: Javascript](https://img.shields.io/lgtm/grade/javascript/g/kuzzleio/koncorde.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/kuzzleio/koncorde/context:javascript) 3 | [![Total Alerts](https://img.shields.io/lgtm/alerts/g/kuzzleio/koncorde.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/kuzzleio/koncorde/alerts) 4 | 5 | # Koncorde 6 | 7 | This module is a reverse-search engine. 8 | 9 | Instead of indexing data and searching for them using filters, Koncorde does the opposite: it indexes search filters, and returns the corresponding ones when presented with data. 10 | 11 | * an arbitrary large number of filters can be registered and indexed; 12 | * whenever data are submitted to Koncorde, it returns the list of indexed filters matching them; 13 | * Koncorde's [filter syntax](https://github.com/kuzzleio/koncorde/wiki/Filter-Syntax) supports a variety of different matchers, and ways to combine them. 14 | 15 | Koncorde can be used in a variety of ways. For instance: 16 | 17 | * as a base of a notification system, where indexed filters are used as user subscriptions: Koncorde tells which JSON objects verify what subscriptions, making it easy to send events to listening users; 18 | * to verify if JSON objects comply to filters used as validation rules. 19 | 20 | Check our [full documentation](https://github.com/kuzzleio/koncorde/wiki) to know more about Koncorde's API, filter syntax, and more. 21 | 22 | 23 | # Quick start example 24 | 25 | In the following example, we'll listen to objects containing a `position` property, describing a geopoint. We want that geopoint to be 500 meters around a pre-defined starting position. 26 | 27 | This can be described by the following Koncorde filter: 28 | 29 | ```json 30 | { 31 | "geoDistance": { 32 | "position": { 33 | "lat": 43.6073913, 34 | "lon": 3.9109057 35 | }, 36 | "distance": "500m" 37 | } 38 | } 39 | ``` 40 | 41 | All you need to do now is to register this filter to the engine, and use it to test data: 42 | 43 | ```js 44 | import { Koncorde } from 'koncorde'; 45 | 46 | const engine = new Koncorde(); 47 | 48 | const filter = { 49 | geoDistance: { 50 | position: { 51 | lat: 43.6073913, 52 | lon: 3.9109057 53 | }, 54 | distance: "500m" 55 | } 56 | }; 57 | 58 | const filterId = engine.register(filter); 59 | 60 | // Filter Identifiers are seeded. Given the same seed (to be provided to the 61 | // constructor), then the filter IDs stay stable. 62 | console.log(`Filter identifier: ${filterId}`); 63 | 64 | // No match found, returns an empty array (distance is greater than 500m) 65 | console.log(engine.test({ position: { lat: 43.6073913, lon: 5.7 } })); 66 | 67 | // Point within the filter's scope: returns the list of matched filters 68 | // Here we registered just one of them, so the array contains only 1 filter ID 69 | console.log(engine.test({ position: { lat: 43.608, lon: 3.905 } })); 70 | 71 | // No match found, returns an empty array 72 | // (the geopoint in the provided data is not stored in the tested field) 73 | console.log(engine.test({ not_position: { lat: 43.608, lon: 3.905 } })); 74 | ``` 75 | 76 | # Install 77 | 78 | This library is compatible with Node.js version 12.x or higher. 79 | Both a C and a C++ compilers are needed to install its dependencies: Koncorde cannot be used in a browser. 80 | 81 | Koncorde is compatible with either Javascript or Typescript projects. 82 | 83 | To install: 84 | 85 | ``` 86 | npm install koncorde 87 | ``` 88 | 89 | 90 | ## Benchmarks 91 | 92 | The following results are obtained running `node benchmark.js` at the root of the projet. 93 | 94 | ``` 95 | Filter count per tested keyword: 10000 96 | 97 | > Benchmarking keyword: equals 98 | Indexation: time = 0.255s, mem = +39MB 99 | Matching x 10,320,209 ops/sec ±0.70% (95 runs sampled) 100 | Filters removal: time = 0.018s 101 | 102 | > Benchmarking keyword: exists 103 | Indexation: time = 0.285s, mem = +20MB 104 | Matching x 5,047,932 ops/sec ±0.23% (97 runs sampled) 105 | Filters removal: time = 0.021s 106 | 107 | > Benchmarking keyword: geoBoundingBox 108 | Indexation: time = 0.685s, mem = +-8MB 109 | Matching x 1,322,528 ops/sec ±0.52% (94 runs sampled) 110 | Filters removal: time = 0.092s 111 | 112 | > Benchmarking keyword: geoDistance 113 | Indexation: time = 1.052s, mem = +3MB 114 | Matching x 1,656,882 ops/sec ±0.65% (96 runs sampled) 115 | Filters removal: time = 0.094s 116 | 117 | > Benchmarking keyword: geoDistanceRange 118 | Indexation: time = 1.551s, mem = +20MB 119 | Matching x 1,344,257 ops/sec ±2.83% (90 runs sampled) 120 | Filters removal: time = 0.101s 121 | 122 | > Benchmarking keyword: geoPolygon (5 vertices) 123 | Indexation: time = 0.818s, mem = +-74MB 124 | Matching x 112,091 ops/sec ±0.54% (97 runs sampled) 125 | Filters removal: time = 0.098s 126 | 127 | > Benchmarking keyword: in (5 random values) 128 | Indexation: time = 0.974s, mem = +90MB 129 | Matching x 3,579,507 ops/sec ±2.93% (92 runs sampled) 130 | Filters removal: time = 0.058s 131 | 132 | > Benchmarking keyword: range (random bounds) 133 | Indexation: time = 0.276s, mem = +-72MB 134 | Matching x 122,311 ops/sec ±1.28% (96 runs sampled) 135 | Filters removal: time = 0.074s 136 | ``` 137 | 138 | _(results obtained with node v16.2.0)_ 139 | -------------------------------------------------------------------------------- /test/keywords/notmatch.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 5 | const { Koncorde } = require("../../"); 6 | 7 | describe("Koncorde.keyword.notmatch", () => { 8 | let koncorde; 9 | let engine; 10 | 11 | beforeEach(() => { 12 | koncorde = new Koncorde(); 13 | engine = koncorde.engines.get(null); 14 | }); 15 | 16 | function getSubfilter(id) { 17 | return Array.from(engine.filters.get(id).subfilters)[0]; 18 | } 19 | 20 | describe("#standardization", () => { 21 | it("should return the same content, unchanged", () => { 22 | const result = koncorde.transformer.standardizer.standardize({ 23 | not: { 24 | match: { 25 | foo: "bar", 26 | }, 27 | }, 28 | }); 29 | 30 | should(result).match({ not: { match: { foo: "bar" } } }); 31 | }); 32 | }); 33 | 34 | describe("#matching", () => { 35 | it("should not match a document with the subscribed keyword", () => { 36 | koncorde.register({ not: { match: { foo: "bar" } } }); 37 | 38 | should(koncorde.test({ foo: "bar" })) 39 | .be.an.Array() 40 | .and.be.empty(); 41 | }); 42 | 43 | it("should match if the document contains the field with another value", () => { 44 | const id = koncorde.register({ not: { match: { foo: "bar" } } }); 45 | 46 | const result = koncorde.test({ foo: "qux" }); 47 | 48 | should(result).eql([id]); 49 | }); 50 | 51 | it("should match if the document do not contain the registered field", () => { 52 | const id = koncorde.register({ not: { match: { foo: "bar" } } }); 53 | 54 | const result = koncorde.test({ qux: "bar" }); 55 | 56 | should(result).eql([id]); 57 | }); 58 | 59 | it("should match if the document array does not contain all the registered value", () => { 60 | const id = koncorde.register({ not: { match: { foo: ["bar", "baz"] } } }); 61 | 62 | const result = koncorde.test({ foo: ["bar"] }); 63 | 64 | should(result).eql([id]); 65 | }); 66 | 67 | it("should match if the document array does not contain all the registered value", () => { 68 | const id = koncorde.register({ 69 | not: { match: { foo: [{ a: "bar" }, { a: "baz" }] } }, 70 | }); 71 | 72 | const result = koncorde.test({ foo: [{ a: "bar" }, { a: "qux" }] }); 73 | 74 | should(result).eql([id]); 75 | }); 76 | 77 | it("should match a document with the subscribed nested keyword", () => { 78 | const id = koncorde.register({ 79 | not: { 80 | match: { 81 | "foo.bar.baz": "qux", 82 | }, 83 | }, 84 | }); 85 | 86 | const result = koncorde.test({ 87 | foo: { 88 | bar: { 89 | baz: "foobar", 90 | }, 91 | }, 92 | }); 93 | 94 | should(result).be.eql([id]); 95 | }); 96 | 97 | it("should match even if another field was hit before", () => { 98 | koncorde.register({ not: { match: { a: "Jennifer Cardini" } } }); 99 | koncorde.register({ not: { match: { b: "Shonky" } } }); 100 | 101 | should(koncorde.test({ a: "Jennifer Cardini" })) 102 | .be.an.Array() 103 | .length(1); 104 | }); 105 | 106 | it("should match 0 equality", () => { 107 | koncorde.register({ not: { match: { a: 0 } } }); 108 | 109 | should(koncorde.test({ a: 0 })) 110 | .be.an.Array() 111 | .be.empty(); 112 | }); 113 | 114 | it("should match false equality", () => { 115 | koncorde.register({ not: { match: { a: false } } }); 116 | 117 | should(koncorde.test({ a: false })) 118 | .be.an.Array() 119 | .be.empty(); 120 | }); 121 | 122 | it("should match null equality", () => { 123 | koncorde.register({ not: { match: { a: null } } }); 124 | 125 | should(koncorde.test({ a: null })) 126 | .be.an.Array() 127 | .be.empty(); 128 | }); 129 | 130 | it("should match undefined equality", () => { 131 | koncorde.register({ not: { match: { a: undefined } } }); 132 | 133 | should(koncorde.test({ a: undefined })) 134 | .be.an.Array() 135 | .be.empty(); 136 | }); 137 | }); 138 | 139 | describe("#removal", () => { 140 | it("should destroy the whole structure when removing the last item", () => { 141 | const id = koncorde.register({ not: { match: { foo: "bar" } } }); 142 | 143 | koncorde.remove(id); 144 | 145 | should(engine.foPairs).be.an.Object().and.be.empty(); 146 | }); 147 | 148 | it("should remove a single subfilter from a multi-filter condition", () => { 149 | const id1 = koncorde.register({ not: { match: { foo: "bar" } } }); 150 | const id2 = koncorde.register({ 151 | and: [ 152 | { not: { match: { foo: "qux" } } }, 153 | { not: { match: { foo: "bar" } } }, 154 | ], 155 | }); 156 | 157 | koncorde.remove(id1); 158 | 159 | const match = engine.foPairs.get("notmatch"); 160 | const multiSubfilter = getSubfilter(id2); 161 | 162 | should(match).be.an.instanceof(FieldOperand); 163 | should( 164 | match.custom.filters.findIndex( 165 | (f) => f.subfilter.id === multiSubfilter.id && f.value.foo === "qux", 166 | ), 167 | ).be.greaterThan(-1); 168 | should( 169 | match.custom.filters.findIndex( 170 | (f) => f.subfilter.id === multiSubfilter.id && f.value.foo === "bar", 171 | ), 172 | ).be.greaterThan(-1); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /test/keywords/notequals.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 5 | const { Koncorde } = require("../../"); 6 | 7 | describe("Koncorde.keyword.notequals", () => { 8 | let koncorde; 9 | let engine; 10 | 11 | beforeEach(() => { 12 | koncorde = new Koncorde(); 13 | engine = koncorde.engines.get(null); 14 | }); 15 | 16 | describe("#standardization", () => { 17 | it("should return the same content, unchanged", () => { 18 | const result = koncorde.transformer.standardizer.standardize({ 19 | not: { 20 | equals: { 21 | foo: "bar", 22 | }, 23 | }, 24 | }); 25 | 26 | should(result).match({ not: { equals: { foo: "bar" } } }); 27 | }); 28 | }); 29 | 30 | describe("#matching", () => { 31 | it("should not match a document with the subscribed keyword", () => { 32 | koncorde.register({ not: { equals: { foo: "bar" } } }); 33 | 34 | should(koncorde.test({ foo: "bar" })) 35 | .be.an.Array() 36 | .and.be.empty(); 37 | }); 38 | 39 | it("should match if the document contains the field with another value", () => { 40 | const id = koncorde.register({ not: { equals: { foo: "bar" } } }); 41 | 42 | const result = koncorde.test({ foo: "qux" }); 43 | 44 | should(result).eql([id]); 45 | }); 46 | 47 | it("should match if the document do not contain the registered field", () => { 48 | const id = koncorde.register({ not: { equals: { foo: "bar" } } }); 49 | 50 | const result = koncorde.test({ qux: "bar" }); 51 | 52 | should(result).eql([id]); 53 | }); 54 | 55 | it("should match a document with the subscribed nested keyword", () => { 56 | const id = koncorde.register({ 57 | not: { 58 | equals: { 59 | "foo.bar.baz": "qux", 60 | }, 61 | }, 62 | }); 63 | 64 | const result = koncorde.test({ 65 | foo: { 66 | bar: { 67 | baz: "foobar", 68 | }, 69 | }, 70 | }); 71 | 72 | should(result).be.eql([id]); 73 | }); 74 | 75 | it("should match even if another field was hit before", () => { 76 | koncorde.register({ not: { equals: { a: "Jennifer Cardini" } } }); 77 | koncorde.register({ not: { equals: { b: "Shonky" } } }); 78 | 79 | should(koncorde.test({ a: "Jennifer Cardini" })) 80 | .be.an.Array() 81 | .length(1); 82 | }); 83 | 84 | it("should match 0 equality", () => { 85 | koncorde.register({ not: { equals: { a: 0 } } }); 86 | 87 | should(koncorde.test({ a: 0 })) 88 | .be.an.Array() 89 | .be.empty(); 90 | }); 91 | 92 | it("should match false equality", () => { 93 | koncorde.register({ not: { equals: { a: false } } }); 94 | 95 | should(koncorde.test({ a: false })) 96 | .be.an.Array() 97 | .be.empty(); 98 | }); 99 | 100 | it("should match null equality", () => { 101 | koncorde.register({ not: { equals: { a: null } } }); 102 | 103 | should(koncorde.test({ a: null })) 104 | .be.an.Array() 105 | .be.empty(); 106 | }); 107 | }); 108 | 109 | describe("#removal", () => { 110 | it("should destroy the whole structure when removing the last item", () => { 111 | const id = koncorde.register({ not: { equals: { foo: "bar" } } }); 112 | 113 | koncorde.remove(id); 114 | 115 | should(engine.foPairs).be.empty(); 116 | }); 117 | 118 | it("should remove a single subfilter from a multi-filter condition", () => { 119 | const id1 = koncorde.register({ not: { equals: { foo: "bar" } } }); 120 | const id2 = koncorde.register({ 121 | and: [ 122 | { not: { equals: { foo: "qux" } } }, 123 | { not: { equals: { foo: "bar" } } }, 124 | ], 125 | }); 126 | 127 | const subfilter = Array.from(engine.filters.get(id2).subfilters)[0]; 128 | 129 | koncorde.remove(id1); 130 | 131 | const storage = engine.foPairs.get("notequals"); 132 | 133 | should(storage).be.instanceOf(FieldOperand); 134 | should(storage.fields.get("foo")).instanceOf(Map); 135 | should(storage.fields.get("foo").size).eql(2); 136 | should(storage.fields.get("foo").get("bar")).eql(new Set([subfilter])); 137 | should(storage.fields.get("foo").get("qux")).eql(new Set([subfilter])); 138 | }); 139 | 140 | it("should remove a value from the list if its last subfilter is removed", () => { 141 | const id1 = koncorde.register({ not: { equals: { foo: "bar" } } }); 142 | const id2 = koncorde.register({ 143 | and: [ 144 | { not: { equals: { foo: "qux" } } }, 145 | { not: { equals: { foo: "bar" } } }, 146 | ], 147 | }); 148 | 149 | koncorde.remove(id2); 150 | 151 | const storage = engine.foPairs.get("notequals"); 152 | const barSubfilter = Array.from(engine.filters.get(id1).subfilters)[0]; 153 | 154 | should(storage).be.instanceOf(FieldOperand); 155 | should(storage.fields.get("foo").get("bar")).match( 156 | new Set([barSubfilter]), 157 | ); 158 | should(storage.fields.get("foo").get("qux")).undefined(); 159 | }); 160 | 161 | it("should remove a field from the list if its last value to test is removed", () => { 162 | const id1 = koncorde.register({ not: { equals: { foo: "bar" } } }); 163 | const id2 = koncorde.register({ not: { equals: { baz: "qux" } } }); 164 | 165 | const barSubfilter = Array.from(engine.filters.get(id1).subfilters)[0]; 166 | const storage = engine.foPairs.get("notequals"); 167 | 168 | should(storage.fields).have.keys("foo", "baz"); 169 | 170 | koncorde.remove(id2); 171 | 172 | should(storage).be.instanceOf(FieldOperand); 173 | should(storage.fields.get("foo").get("bar")).match( 174 | new Set([barSubfilter]), 175 | ); 176 | should(storage.fields.get("baz")).be.undefined(); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [4.7.0](https://github.com/kuzzleio/koncorde/compare/v4.6.0...v4.7.0) (2025-12-02) 2 | 3 | 4 | ### Features 5 | 6 | * bump deps ([8fbf06a](https://github.com/kuzzleio/koncorde/commit/8fbf06afae1e74e4c771ae1ec4fb456061e8ca63)) 7 | * bump deps ([5250529](https://github.com/kuzzleio/koncorde/commit/52505290245d5c83ba7d81796f36b7e7989d468e)) 8 | * compatible with nodejs24 ([ac6cc21](https://github.com/kuzzleio/koncorde/commit/ac6cc216e99c2d9b5abca4151ff8ef2b1c5443b3)) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * semantic-release plugin is missing ([2654b99](https://github.com/kuzzleio/koncorde/commit/2654b9956b89d4ce3365c015a67b344f11d3d86c)) 14 | 15 | ## [4.7.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.6.0...v4.7.0-beta.1) (2025-12-02) 16 | 17 | 18 | ### Features 19 | 20 | * bump deps ([8fbf06a](https://github.com/kuzzleio/koncorde/commit/8fbf06afae1e74e4c771ae1ec4fb456061e8ca63)) 21 | * bump deps ([5250529](https://github.com/kuzzleio/koncorde/commit/52505290245d5c83ba7d81796f36b7e7989d468e)) 22 | * compatible with nodejs24 ([ac6cc21](https://github.com/kuzzleio/koncorde/commit/ac6cc216e99c2d9b5abca4151ff8ef2b1c5443b3)) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * semantic-release plugin is missing ([2654b99](https://github.com/kuzzleio/koncorde/commit/2654b9956b89d4ce3365c015a67b344f11d3d86c)) 28 | 29 | # [4.6.0](https://github.com/kuzzleio/koncorde/compare/v4.5.0...v4.6.0) (2025-01-15) 30 | 31 | 32 | ### Features 33 | 34 | * update deps ([f3410c9](https://github.com/kuzzleio/koncorde/commit/f3410c9776519d63851b1a207d9b944756c4a6ac)) 35 | 36 | # [4.6.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.5.0...v4.6.0-beta.1) (2025-01-15) 37 | 38 | 39 | ### Features 40 | 41 | * update deps ([f3410c9](https://github.com/kuzzleio/koncorde/commit/f3410c9776519d63851b1a207d9b944756c4a6ac)) 42 | 43 | # [4.5.0](https://github.com/kuzzleio/koncorde/compare/v4.4.0...v4.5.0) (2025-01-15) 44 | 45 | 46 | ### Features 47 | 48 | * move to espressio-minimizer ([34b1165](https://github.com/kuzzleio/koncorde/commit/34b11653daf3859cb1ec7a007f4a4304c4f85c42)) 49 | 50 | # [4.5.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.4.0...v4.5.0-beta.1) (2025-01-15) 51 | 52 | 53 | ### Features 54 | 55 | * move to espressio-minimizer ([34b1165](https://github.com/kuzzleio/koncorde/commit/34b11653daf3859cb1ec7a007f4a4304c4f85c42)) 56 | 57 | # [4.4.0](https://github.com/kuzzleio/koncorde/compare/v4.3.0...v4.4.0) (2025-01-07) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * typo in ci ([904071e](https://github.com/kuzzleio/koncorde/commit/904071e1d09b0eeea96f314feb0332e223a6d8dd)) 63 | 64 | 65 | ### Features 66 | 67 | * update deps ([ff9cc13](https://github.com/kuzzleio/koncorde/commit/ff9cc13c5ee0fb5f09be7a8f0ae9042e30af4dc9)) 68 | 69 | # [4.4.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.3.0...v4.4.0-beta.1) (2025-01-07) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * typo in ci ([904071e](https://github.com/kuzzleio/koncorde/commit/904071e1d09b0eeea96f314feb0332e223a6d8dd)) 75 | 76 | 77 | ### Features 78 | 79 | * update deps ([ff9cc13](https://github.com/kuzzleio/koncorde/commit/ff9cc13c5ee0fb5f09be7a8f0ae9042e30af4dc9)) 80 | 81 | # [4.3.0](https://github.com/kuzzleio/koncorde/compare/v4.2.0...v4.3.0) (2023-09-18) 82 | 83 | 84 | ### Bug Fixes 85 | 86 | * **depedencies:** update version of boost-geospatial-index ([27dc6f9](https://github.com/kuzzleio/koncorde/commit/27dc6f9f8c523681feb0d02b10517eba4e613b24)) 87 | * **deps:** fIxing all deps, use true release of boost ([7ba7a4c](https://github.com/kuzzleio/koncorde/commit/7ba7a4c2ca677bccbe6c925bd942a2419ff7d897)) 88 | 89 | 90 | ### Features 91 | 92 | * **depedencies:** update deps to their latest version, focus on boost-geospatial-index ([523bc77](https://github.com/kuzzleio/koncorde/commit/523bc7719993f14c0a6f96b78599dbca3df687dc)) 93 | 94 | # [4.3.0-beta.3](https://github.com/kuzzleio/koncorde/compare/v4.3.0-beta.2...v4.3.0-beta.3) (2023-09-18) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **deps:** fIxing all deps, use true release of boost ([7ba7a4c](https://github.com/kuzzleio/koncorde/commit/7ba7a4c2ca677bccbe6c925bd942a2419ff7d897)) 100 | 101 | # [4.3.0-beta.2](https://github.com/kuzzleio/koncorde/compare/v4.3.0-beta.1...v4.3.0-beta.2) (2023-09-18) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * **depedencies:** update version of boost-geospatial-index ([27dc6f9](https://github.com/kuzzleio/koncorde/commit/27dc6f9f8c523681feb0d02b10517eba4e613b24)) 107 | 108 | # [4.3.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.2.0...v4.3.0-beta.1) (2023-09-18) 109 | 110 | 111 | ### Features 112 | 113 | * **depedencies:** update deps to their latest version, focus on boost-geospatial-index ([523bc77](https://github.com/kuzzleio/koncorde/commit/523bc7719993f14c0a6f96b78599dbca3df687dc)) 114 | 115 | # [4.2.0](https://github.com/kuzzleio/koncorde/compare/v4.1.0...v4.2.0) (2023-08-30) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * **actions:** remove workflow doc deploy for koncorde as there is no deployed version ([d5451f9](https://github.com/kuzzleio/koncorde/commit/d5451f9947654b1c8d4e80bde46c8898792c8976)) 121 | * **ci:** should fix ci and cd ([d41e9c1](https://github.com/kuzzleio/koncorde/commit/d41e9c14bdfaec883d362f6efd3a3c010e03266d)) 122 | 123 | 124 | ### Features 125 | 126 | * **semantic-release:** add semantic release support ([f065013](https://github.com/kuzzleio/koncorde/commit/f0650130643b262b64049f13b2a20310538eadb5)) 127 | 128 | # [4.2.0-beta.2](https://github.com/kuzzleio/koncorde/compare/v4.2.0-beta.1...v4.2.0-beta.2) (2023-08-30) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * **actions:** remove workflow doc deploy for koncorde as there is no deployed version ([d5451f9](https://github.com/kuzzleio/koncorde/commit/d5451f9947654b1c8d4e80bde46c8898792c8976)) 134 | 135 | # [4.2.0-beta.1](https://github.com/kuzzleio/koncorde/compare/v4.1.0...v4.2.0-beta.1) (2023-08-30) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * **ci:** should fix ci and cd ([d41e9c1](https://github.com/kuzzleio/koncorde/commit/d41e9c14bdfaec883d362f6efd3a3c010e03266d)) 141 | 142 | 143 | ### Features 144 | 145 | * **semantic-release:** add semantic release support ([f065013](https://github.com/kuzzleio/koncorde/commit/f0650130643b262b64049f13b2a20310538eadb5)) 146 | -------------------------------------------------------------------------------- /test/keywords/match.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 6 | const { Koncorde } = require("../../lib"); 7 | 8 | describe("Koncorde.keyword.match", () => { 9 | let koncorde; 10 | let engine; 11 | 12 | beforeEach(() => { 13 | koncorde = new Koncorde(); 14 | engine = koncorde.engines.get(null); 15 | }); 16 | 17 | function getSubfilter(id) { 18 | return Array.from(engine.filters.get(id).subfilters)[0]; 19 | } 20 | 21 | describe("#validation", () => { 22 | it("should reject non-object filters", () => { 23 | should(() => koncorde.validate({ match: ["foo", "bar"] })).throw({ 24 | keyword: "match", 25 | message: '"match": must be an object', 26 | path: "match", 27 | }); 28 | }); 29 | 30 | it("should reject empty filters", () => { 31 | should(() => koncorde.validate({ match: {} })).throw({ 32 | keyword: "match", 33 | message: '"match": must be a non-empty object', 34 | path: "match", 35 | }); 36 | }); 37 | 38 | it("should validate filters with number argument", () => { 39 | should(() => koncorde.validate({ match: { foo: 42 } })).not.throw(); 40 | }); 41 | 42 | it("should validate filters with array argument", () => { 43 | should(() => koncorde.validate({ match: { foo: [42] } })).not.throw(); 44 | }); 45 | 46 | it("should validate filters with object argument", () => { 47 | should(() => koncorde.validate({ match: { foo: {} } })).not.throw(); 48 | }); 49 | 50 | it("should validate filters with object argument", () => { 51 | should(() => 52 | koncorde.validate({ match: { foo: undefined } }), 53 | ).not.throw(); 54 | }); 55 | 56 | it("should validate filters with null argument", () => { 57 | should(() => koncorde.validate({ match: { foo: null } })).not.throw(); 58 | }); 59 | 60 | it("should validate filters with boolean argument", () => { 61 | should(() => koncorde.validate({ match: { foo: true } })).not.throw(); 62 | }); 63 | 64 | it("should validate filters with a string argument", () => { 65 | should(() => koncorde.validate({ match: { foo: "bar" } })).not.throw(); 66 | }); 67 | 68 | it("should validate filters with an empty string argument", () => { 69 | should(() => koncorde.validate({ match: { foo: "" } })).not.throw(); 70 | }); 71 | }); 72 | 73 | describe("#standardization", () => { 74 | it("should return the same content, unchanged", () => { 75 | should( 76 | koncorde.transformer.standardizer.standardize({ 77 | match: { foo: "bar" }, 78 | }), 79 | ).match({ match: { foo: "bar" } }); 80 | }); 81 | }); 82 | 83 | describe("#matching", () => { 84 | it("should match a document if partially equal", () => { 85 | const id = koncorde.register({ match: { foo: "bar" } }); 86 | const result = koncorde.test({ foo: "bar", bar: "baz" }); 87 | 88 | should(result).be.an.Array().and.not.empty(); 89 | should(result[0]).be.eql(id); 90 | }); 91 | 92 | it("should match a document if the array contains all the element of the filter", () => { 93 | const id = koncorde.register({ match: { foo: [4, 2] } }); 94 | const result = koncorde.test({ foo: [1, 4, 9, 2] }); 95 | 96 | should(result).be.an.Array().and.not.empty(); 97 | should(result[0]).be.eql(id); 98 | }); 99 | 100 | it("should match a document if the array contains all the element of the filter and sub arrays and objects matches", () => { 101 | const id = koncorde.register({ match: { foo: [{ a: 1 }] } }); 102 | const result = koncorde.test({ foo: [{ b: 1 }, { a: 1, b: 2 }] }); 103 | 104 | should(result).be.an.Array().and.not.empty(); 105 | should(result[0]).be.eql(id); 106 | }); 107 | 108 | it("should not match if the document contains the field with another value", () => { 109 | koncorde.register({ match: { foo: "bar" } }); 110 | 111 | should(koncorde.test({ foo: "qux" })) 112 | .be.an.Array() 113 | .and.be.empty(); 114 | }); 115 | 116 | it("should not match if the document contains another field with the registered value", () => { 117 | koncorde.register({ match: { foo: "bar" } }); 118 | should(koncorde.test({ qux: "bar" })) 119 | .be.an.Array() 120 | .and.be.empty(); 121 | }); 122 | 123 | // see https://github.com/kuzzleio/koncorde/issues/13 124 | it("should skip the matching if the document tested property is not of the same type than the known values", () => { 125 | koncorde.register({ match: { foo: "bar" } }); 126 | 127 | should(koncorde.test({ foo: ["bar"] })) 128 | .be.an.Array() 129 | .and.empty(); 130 | 131 | should(koncorde.test({ foo: { bar: true } })) 132 | .be.an.Array() 133 | .and.empty(); 134 | }); 135 | 136 | it("should match a document with the subscribed nested keyword", () => { 137 | const id = koncorde.register({ match: { "foo.bar.baz": "qux" } }); 138 | const result = koncorde.test({ foo: { bar: { baz: "qux" } } }); 139 | 140 | should(result).be.an.Array().and.not.empty(); 141 | should(result[0]).be.eql(id); 142 | }); 143 | 144 | it("should match 0 equality", () => { 145 | koncorde.register({ match: { a: 0 } }); 146 | should(koncorde.test({ a: 0 })) 147 | .be.an.Array() 148 | .length(1); 149 | }); 150 | 151 | it("should match false equality", () => { 152 | koncorde.register({ match: { a: false } }); 153 | should(koncorde.test({ a: false })) 154 | .be.an.Array() 155 | .length(1); 156 | }); 157 | 158 | it("should match null equality", () => { 159 | koncorde.register({ match: { a: null } }); 160 | should(koncorde.test({ a: null })) 161 | .be.an.Array() 162 | .length(1); 163 | }); 164 | 165 | it("should match undefined equality", () => { 166 | koncorde.register({ match: { a: undefined } }); 167 | should(koncorde.test({ a: undefined })) 168 | .be.an.Array() 169 | .length(1); 170 | }); 171 | }); 172 | 173 | describe("#removal", () => { 174 | it("should destroy the whole structure when removing the last item", () => { 175 | const id = koncorde.register({ match: { foo: "bar" } }); 176 | 177 | koncorde.remove(id); 178 | 179 | should(engine.foPairs).be.an.Object().and.be.empty(); 180 | }); 181 | 182 | it("should remove a single subfilter from a multi-filter condition", () => { 183 | const id1 = koncorde.register({ match: { foo: "bar" } }); 184 | const id2 = koncorde.register({ 185 | and: [{ match: { baz: "qux" } }, { match: { foo: "bar" } }], 186 | }); 187 | 188 | koncorde.remove(id1); 189 | 190 | const match = engine.foPairs.get("match"); 191 | const multiSubfilter = getSubfilter(id2); 192 | 193 | should(match).be.an.instanceof(FieldOperand); 194 | should( 195 | match.custom.filters.findIndex( 196 | (f) => f.subfilter.id === multiSubfilter.id && f.value.baz === "qux", 197 | ), 198 | ).be.greaterThan(-1); 199 | should( 200 | match.custom.filters.findIndex( 201 | (f) => f.subfilter.id === multiSubfilter.id && f.value.foo === "bar", 202 | ), 203 | ).be.greaterThan(-1); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /lib/engine/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | * Kuzzle, a backend software, self-hostable and ready to use 5 | * to power modern apps 6 | * 7 | * Copyright 2015-2021 Kuzzle 8 | * mailto: support AT kuzzle.io 9 | * website: http://kuzzle.io 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * https://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | */ 23 | 24 | const { OperandsStorage } = require("./storeOperands"); 25 | const OperandsRemoval = require("./removeOperands"); 26 | const Filter = require("./objects/filter"); 27 | const Subfilter = require("./objects/subfilter"); 28 | const Condition = require("./objects/condition"); 29 | const FieldOperand = require("./objects/fieldOperand"); 30 | const Matcher = require("./matcher"); 31 | const { hash } = require("../util/hash"); 32 | 33 | /** 34 | * Real-time engine 35 | * 36 | * @class Engine 37 | */ 38 | class Engine { 39 | constructor(config) { 40 | this.seed = config.seed; 41 | this.storeOperand = new OperandsStorage(config); 42 | this.removeOperand = new OperandsRemoval(config); 43 | this.matcher = new Matcher(); 44 | 45 | /** 46 | * Filter => Subfilter link table 47 | * A filter is made of subfilters. Each subfilter is to be tested 48 | * against OR operands, meaning if at least 1 subfilter matches, the 49 | * whole filter matches. 50 | * 51 | * @type {Map.} 52 | */ 53 | this.filters = new Map(); 54 | 55 | /** 56 | * Subfilters link table 57 | * 58 | * A subfilter is a set of conditions to be tested against 59 | * AND operands. If at least 1 condition returns false, then 60 | * the whole subfilter is false. 61 | */ 62 | this.subfilters = new Map(); 63 | 64 | /** 65 | * Conditions description 66 | * A condition is made of a Koncorde keyword, a document field name, and 67 | * the associated test values 68 | */ 69 | this.conditions = new Map(); 70 | 71 | /** 72 | * Contains field-operand pairs to be tested 73 | * A field-operand pair is a Koncorde keyword applied to a document field 74 | */ 75 | this.foPairs = new Map(); 76 | } 77 | 78 | /** 79 | * Remove a filter ID from the storage 80 | * Returns the number of indexed filters remaining. 81 | * 82 | * @param {string} filterId 83 | * @param {Number} 84 | */ 85 | remove(filterId) { 86 | const filter = this.filters.get(filterId); 87 | 88 | if (!filter) { 89 | return; 90 | } 91 | 92 | for (const subfilter of filter.subfilters) { 93 | if (subfilter.filters.size === 1) { 94 | for (const condition of subfilter.conditions) { 95 | this.removeOperand[condition.keyword]( 96 | this.foPairs, 97 | subfilter, 98 | condition, 99 | ); 100 | 101 | if (condition.subfilters.size === 1) { 102 | this.conditions.delete(condition.id); 103 | } else { 104 | condition.subfilters.delete(subfilter); 105 | } 106 | } 107 | 108 | this.subfilters.delete(subfilter.id); 109 | } else { 110 | subfilter.filters.delete(filter); 111 | } 112 | } 113 | 114 | this.filters.delete(filterId); 115 | 116 | return this.filters.size; 117 | } 118 | 119 | /** 120 | * Decomposes and stores a normalized filter 121 | * 122 | * @param {NormalizedFilter} normalized 123 | */ 124 | store(normalized) { 125 | if (this.filters.has(normalized.id)) { 126 | return; 127 | } 128 | 129 | const filter = new Filter(normalized.id, normalized.filter); 130 | this.filters.set(normalized.id, filter); 131 | 132 | for (let i = 0; i < normalized.filter.length; i++) { 133 | const sf = normalized.filter[i]; 134 | const sfResult = this._addSubfilter(filter, sf); 135 | 136 | if (sfResult.created) { 137 | const subfilter = this.subfilters.get(sfResult.id); 138 | const addedConditions = this._addConditions(subfilter, sf); 139 | 140 | for (let j = 0; j < addedConditions.length; j++) { 141 | const cond = addedConditions[j]; 142 | let operand = this.foPairs.get(cond.keyword); 143 | 144 | if (!operand) { 145 | operand = new FieldOperand(); 146 | this.foPairs.set(cond.keyword, operand); 147 | } 148 | 149 | this.storeOperand[cond.keyword](operand, subfilter, cond); 150 | } 151 | } 152 | } 153 | } 154 | 155 | /** 156 | * Forward data matching to the embedded matcher 157 | * 158 | * @param {Object} data 159 | * @return {Array.} 160 | */ 161 | match(data) { 162 | return this.matcher.match(this.foPairs, data); 163 | } 164 | 165 | /** 166 | * Adds a subfilter to the subfilters structure. 167 | * Link it to the corresponding filter 168 | * 169 | * Return value contains the "created" boolean indicating 170 | * if the subfilter has been created or updated. 171 | * If false, nothing changed. 172 | * 173 | * @param {Array} subfilter 174 | * @return {{created: Boolean, id: String}} 175 | */ 176 | _addSubfilter(filter, subfilter) { 177 | const sfId = hash(this.seed, subfilter); 178 | const sfRef = this.subfilters.get(sfId); 179 | let created = true; 180 | 181 | if (sfRef) { 182 | created = false; 183 | sfRef.filters.add(filter); 184 | filter.subfilters.add(sfRef); 185 | } else { 186 | const sfObj = new Subfilter(sfId, filter); 187 | this.subfilters.set(sfId, sfObj); 188 | filter.subfilters.add(sfObj); 189 | } 190 | 191 | return { created, id: sfId }; 192 | } 193 | 194 | /** 195 | * Adds conditions registered in a subfilter to the conditions 196 | * structure, and link them to the corresponding subfilter structure 197 | * 198 | * Returns the list of created conditions 199 | * 200 | * @param {object} subfilter - link to the corresponding subfilter in the 201 | * subfilters structure 202 | * @param {Array} conditions - array of conditions 203 | * @return {Array} 204 | */ 205 | _addConditions(subfilter, conditions) { 206 | const diff = []; 207 | 208 | for (let i = 0; i < conditions.length; i++) { 209 | const cond = conditions[i]; 210 | const cId = hash(this.seed, cond); 211 | const condLink = this.conditions.get(cId); 212 | 213 | if (condLink) { 214 | if (!condLink.subfilters.has(subfilter)) { 215 | condLink.subfilters.add(subfilter); 216 | subfilter.conditions.add(condLink); 217 | diff.push(condLink); 218 | } 219 | } else { 220 | const keyword = Object.keys(cond).filter((k) => k !== "not")[0]; 221 | const condObj = new Condition( 222 | cId, 223 | subfilter, 224 | cond.not ? "not" + keyword : keyword, 225 | cond[keyword], 226 | ); 227 | 228 | this.conditions.set(cId, condObj); 229 | subfilter.conditions.add(condObj); 230 | diff.push(condObj); 231 | } 232 | } 233 | 234 | return diff; 235 | } 236 | } 237 | 238 | module.exports = { Engine }; 239 | -------------------------------------------------------------------------------- /test/transform/canonical.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const { Canonical } = require("../../lib/transform/canonical"); 6 | 7 | describe("api/koncorde/transform/canonical", () => { 8 | let canonical; 9 | 10 | beforeEach(() => { 11 | canonical = new Canonical({}); 12 | }); 13 | 14 | describe("_removeImpossiblePredicates", () => { 15 | it("foo === A && foo === B", () => { 16 | const filtered = canonical._removeImpossiblePredicates([ 17 | [{ equals: { foo: "bar" }, not: false }], 18 | [ 19 | { equals: { foo: "bar" }, not: false }, 20 | { equals: { foo: "baz" }, not: false }, 21 | { exists: { path: "anotherfield", array: false }, not: false }, 22 | ], 23 | ]); 24 | 25 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 26 | }); 27 | 28 | it("foo === A && foo does not exist", () => { 29 | const filtered = canonical._removeImpossiblePredicates([ 30 | [{ equals: { foo: "bar" }, not: false }], 31 | [ 32 | { equals: { foo: "bar" }, not: false }, 33 | { exists: { path: "foo", array: true, value: "bar" }, not: true }, 34 | { exists: "anotherField", not: false }, 35 | ], 36 | ]); 37 | 38 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 39 | }); 40 | 41 | it("foo does not exist && foo === A", () => { 42 | const filtered = canonical._removeImpossiblePredicates([ 43 | [{ equals: { foo: "bar" }, not: false }], 44 | [ 45 | { exists: { path: "foo", array: false, value: null }, not: true }, 46 | { equals: { foo: "bar" }, not: false }, 47 | { exists: "anotherField", not: false }, 48 | ], 49 | ]); 50 | 51 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 52 | }); 53 | 54 | it("foo exists && foo does not exist", () => { 55 | const filtered = canonical._removeImpossiblePredicates([ 56 | [{ equals: { foo: "bar" }, not: false }], 57 | [ 58 | { exists: { path: "foo", array: true, value: 42 }, not: false }, 59 | { exists: { path: "foo", array: false, value: null }, not: true }, 60 | { exists: "anotherField", not: false }, 61 | ], 62 | ]); 63 | 64 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 65 | }); 66 | 67 | it("foo does not exist && foo exists", () => { 68 | const filtered = canonical._removeImpossiblePredicates([ 69 | [{ equals: { foo: "bar" }, not: false }], 70 | [ 71 | { exists: { path: "foo", array: true, value: 42 }, not: true }, 72 | { exists: { path: "foo", array: false, value: null }, not: false }, 73 | { exists: "anotherField", not: false }, 74 | ], 75 | ]); 76 | 77 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 78 | }); 79 | 80 | it("foo === A && foo !== A", () => { 81 | const filtered = canonical._removeImpossiblePredicates([ 82 | [{ equals: { foo: "bar" }, not: false }], 83 | [ 84 | { equals: { foo: "bar" }, not: true }, 85 | { equals: { foo: "bar" }, not: false }, 86 | { exists: { path: "foo", array: false, value: null }, not: false }, 87 | ], 88 | ]); 89 | 90 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 91 | }); 92 | 93 | it("foo !== A && foo === A", () => { 94 | const filtered = canonical._removeImpossiblePredicates([ 95 | [{ equals: { foo: "bar" }, not: false }], 96 | [ 97 | { equals: { foo: "bar" }, not: false }, 98 | { equals: { foo: "bar" }, not: true }, 99 | { exists: { path: "foo", array: false, value: null }, not: false }, 100 | ], 101 | ]); 102 | 103 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 104 | }); 105 | 106 | it("foo === 9 && foo < 5", () => { 107 | const filtered = canonical._removeImpossiblePredicates([ 108 | [{ equals: { foo: "bar" }, not: false }], 109 | [ 110 | { range: { foo: { lt: 5 } }, not: false }, 111 | { equals: { foo: 9 }, not: false }, 112 | { 113 | exists: { path: "anotherfield", array: false, value: null }, 114 | not: false, 115 | }, 116 | ], 117 | ]); 118 | 119 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 120 | }); 121 | 122 | it("foo < 5 && foo === 9", () => { 123 | const filtered = canonical._removeImpossiblePredicates([ 124 | [{ equals: { foo: "bar" }, not: false }], 125 | [ 126 | { equals: { foo: 9 }, not: false }, 127 | { range: { foo: { lt: 5 } }, not: false }, 128 | { 129 | exists: { path: "anotherfield", array: false, value: null }, 130 | not: false, 131 | }, 132 | ], 133 | ]); 134 | 135 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 136 | }); 137 | 138 | it("foo === 9 && foo <= 5", () => { 139 | const filtered = canonical._removeImpossiblePredicates([ 140 | [{ equals: { foo: "bar" }, not: false }], 141 | [ 142 | { range: { foo: { lte: 5 } }, not: false }, 143 | { equals: { foo: 9 }, not: false }, 144 | { 145 | exists: { path: "anotherfield", array: false, value: null }, 146 | not: false, 147 | }, 148 | ], 149 | ]); 150 | 151 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 152 | }); 153 | 154 | it("foo <= 5 && foo === 9", () => { 155 | const filtered = canonical._removeImpossiblePredicates([ 156 | [{ equals: { foo: "bar" }, not: false }], 157 | [ 158 | { equals: { foo: 9 }, not: false }, 159 | { range: { foo: { lte: 5 } }, not: false }, 160 | { 161 | exists: { path: "anotherfield", array: false, value: null }, 162 | not: false, 163 | }, 164 | ], 165 | ]); 166 | 167 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 168 | }); 169 | 170 | it("foo == 9 && foo > 10", () => { 171 | const filtered = canonical._removeImpossiblePredicates([ 172 | [{ equals: { foo: "bar" }, not: false }], 173 | [ 174 | { range: { foo: { gt: 10 } }, not: false }, 175 | { equals: { foo: 9 }, not: false }, 176 | { 177 | exists: { path: "anotherfield", array: false, value: null }, 178 | not: false, 179 | }, 180 | ], 181 | ]); 182 | 183 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 184 | }); 185 | 186 | it("foo > 10 && foo == 9", () => { 187 | const filtered = canonical._removeImpossiblePredicates([ 188 | [{ equals: { foo: "bar" }, not: false }], 189 | [ 190 | { equals: { foo: 9 }, not: false }, 191 | { range: { foo: { gt: 10 } }, not: false }, 192 | { 193 | exists: { path: "anotherfield", array: false, value: null }, 194 | not: false, 195 | }, 196 | ], 197 | ]); 198 | 199 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 200 | }); 201 | 202 | it("foo == 9 && foo >= 10", () => { 203 | const filtered = canonical._removeImpossiblePredicates([ 204 | [{ equals: { foo: "bar" }, not: false }], 205 | [ 206 | { range: { foo: { gte: 10 } }, not: false }, 207 | { equals: { foo: 9 }, not: false }, 208 | { 209 | exists: { path: "anotherfield", array: false, value: null }, 210 | not: false, 211 | }, 212 | ], 213 | ]); 214 | 215 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 216 | }); 217 | 218 | it("foo >= 10 && foo == 9", () => { 219 | const filtered = canonical._removeImpossiblePredicates([ 220 | [{ equals: { foo: "bar" }, not: false }], 221 | [ 222 | { equals: { foo: 9 }, not: false }, 223 | { range: { foo: { gte: 10 } }, not: false }, 224 | { 225 | exists: { path: "anotherfield", array: false, value: null }, 226 | not: false, 227 | }, 228 | ], 229 | ]); 230 | 231 | should(filtered).eql([[{ equals: { foo: "bar" }, not: false }]]); 232 | }); 233 | 234 | it('should return a single "nothing" operator if all clauses are anti-totologies', () => { 235 | const filtered = canonical._removeImpossiblePredicates([ 236 | [ 237 | { equals: { foo: 1 }, not: false }, 238 | { equals: { foo: 2 }, not: false }, 239 | ], 240 | [ 241 | { exists: { path: "bar", array: false, value: null }, not: false }, 242 | { exists: { path: "bar", array: true, value: "qux" }, not: true }, 243 | ], 244 | ]); 245 | 246 | should(filtered).eql([[{ nothing: true }]]); 247 | }); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /test/keywords/geoPolygon.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 6 | const { Koncorde } = require("../../"); 7 | 8 | describe("Koncorde.keyword.geoPolygon", () => { 9 | let koncorde; 10 | let engine; 11 | let standardize; 12 | const polygon = { 13 | points: [ 14 | { lat: 43.6021299, lon: 3.8989713 }, 15 | { lat: 43.6057389, lon: 3.8968173 }, 16 | { lat: 43.6092889, lon: 3.8970423 }, 17 | { lat: 43.6100359, lon: 3.9040853 }, 18 | { lat: 43.6069619, lon: 3.9170343 }, 19 | { lat: 43.6076479, lon: 3.9230133 }, 20 | { lat: 43.6038779, lon: 3.9239153 }, 21 | { lat: 43.6019189, lon: 3.9152403 }, 22 | { lat: 43.6036049, lon: 3.9092313 }, 23 | ], 24 | }; 25 | const polygonStandardized = { 26 | geospatial: { 27 | geoPolygon: { 28 | foo: [ 29 | [43.6021299, 3.8989713], 30 | [43.6057389, 3.8968173], 31 | [43.6092889, 3.8970423], 32 | [43.6100359, 3.9040853], 33 | [43.6069619, 3.9170343], 34 | [43.6076479, 3.9230133], 35 | [43.6038779, 3.9239153], 36 | [43.6019189, 3.9152403], 37 | [43.6036049, 3.9092313], 38 | ], 39 | }, 40 | }, 41 | }; 42 | 43 | beforeEach(() => { 44 | koncorde = new Koncorde(); 45 | engine = koncorde.engines.get(null); 46 | standardize = koncorde.transformer.standardizer.standardize.bind( 47 | koncorde.transformer.standardizer, 48 | ); 49 | }); 50 | 51 | describe("#validation/standardization", () => { 52 | it("should reject an empty filter", () => { 53 | should(() => standardize({ geoPolygon: {} })).throw({ 54 | keyword: "geoPolygon", 55 | message: 56 | '"geoPolygon": expected object to have exactly 1 property, got 0', 57 | path: "geoPolygon", 58 | }); 59 | }); 60 | 61 | it("should reject a filter with multiple field attributes", () => { 62 | should(() => 63 | standardize({ geoPolygon: { foo: polygon, bar: polygon } }), 64 | ).throw({ 65 | keyword: "geoPolygon", 66 | message: 67 | '"geoPolygon": expected object to have exactly 1 property, got 2', 68 | path: "geoPolygon", 69 | }); 70 | }); 71 | 72 | it("should reject a filter without a points field", () => { 73 | const filter = { 74 | foo: { 75 | bar: [ 76 | [0, 0], 77 | [5, 5], 78 | [5, 0], 79 | ], 80 | }, 81 | }; 82 | 83 | should(() => standardize({ geoPolygon: filter })).throw({ 84 | keyword: "geoPolygon", 85 | message: '"geoPolygon.foo": the property "points" is missing', 86 | path: "geoPolygon.foo", 87 | }); 88 | }); 89 | 90 | it("should reject a filter with an empty points field", () => { 91 | should(() => standardize({ geoPolygon: { foo: { points: [] } } })).throw({ 92 | keyword: "geoPolygon", 93 | message: 94 | '"geoPolygon.foo.points": at least 3 points are required to build a polygon', 95 | path: "geoPolygon.foo.points", 96 | }); 97 | }); 98 | 99 | it("should reject a polygon with less than 3 points defined", () => { 100 | should(() => 101 | standardize({ 102 | geoPolygon: { 103 | foo: { 104 | points: [ 105 | [0, 0], 106 | [5, 5], 107 | ], 108 | }, 109 | }, 110 | }), 111 | ).throw({ 112 | keyword: "geoPolygon", 113 | message: 114 | '"geoPolygon.foo.points": at least 3 points are required to build a polygon', 115 | path: "geoPolygon.foo.points", 116 | }); 117 | }); 118 | 119 | it("should reject a polygon with a non-array points field", () => { 120 | should(() => 121 | standardize({ geoPolygon: { foo: { points: "foobar" } } }), 122 | ).throw({ 123 | keyword: "geoPolygon", 124 | message: '"geoPolygon.foo.points": must be an array', 125 | path: "geoPolygon.foo.points", 126 | }); 127 | }); 128 | 129 | it("should reject a polygon containing an invalid point format", () => { 130 | const p = polygon.points.slice(); 131 | p.push(42); 132 | should(() => standardize({ geoPolygon: { foo: { points: p } } })).throw({ 133 | keyword: "geoPolygon", 134 | message: '"geoPolygon.foo.points": unrecognized point format "42"', 135 | path: "geoPolygon.foo.points", 136 | }); 137 | }); 138 | 139 | it("should standardize all geopoint types in a single points array", () => { 140 | const points = { 141 | points: [ 142 | { lat: 43.6021299, lon: 3.8989713 }, 143 | { latLon: [43.6057389, 3.8968173] }, 144 | { latLon: { lat: 43.6092889, lon: 3.8970423 } }, 145 | { latLon: "43.6100359, 3.9040853" }, 146 | { latLon: "spfb14kkcwbk" }, 147 | { lat_lon: [43.6076479, 3.9230133] }, 148 | { lat_lon: { lat: 43.6038779, lon: 3.9239153 } }, 149 | { lat_lon: "43.6019189, 3.9152403" }, 150 | { lat_lon: "spfb0cy97tn4" }, 151 | ], 152 | }; 153 | 154 | const result = standardize({ geoPolygon: { foo: points } }); 155 | should(result.geospatial).be.an.Object(); 156 | should(result.geospatial.geoPolygon).be.an.Object(); 157 | should(result.geospatial.geoPolygon.foo).be.an.Object(); 158 | 159 | result.geospatial.geoPolygon.foo.forEach((p, i) => { 160 | should(p[0]).be.approximately( 161 | polygonStandardized.geospatial.geoPolygon.foo[i][0], 162 | 10e-6, 163 | ); 164 | should(p[1]).be.approximately( 165 | polygonStandardized.geospatial.geoPolygon.foo[i][1], 166 | 10e-6, 167 | ); 168 | }); 169 | }); 170 | }); 171 | 172 | describe("#storage", () => { 173 | it("should store a single geoPolygon correctly", () => { 174 | const id = koncorde.register({ geoPolygon: { foo: polygon } }); 175 | 176 | const subfilter = Array.from(engine.filters.get(id).subfilters)[0]; 177 | const storage = engine.foPairs.get("geospatial"); 178 | 179 | should(storage).be.instanceOf(FieldOperand); 180 | should( 181 | storage.fields.get("foo").get(Array.from(subfilter.conditions)[0].id), 182 | ).match(new Set([subfilter])); 183 | }); 184 | 185 | it("should add a subfilter to an already existing condition", () => { 186 | const id1 = koncorde.register({ geoPolygon: { foo: polygon } }); 187 | const id2 = koncorde.register({ 188 | and: [{ geoPolygon: { foo: polygon } }, { equals: { foo: "bar" } }], 189 | }); 190 | 191 | const sf1 = Array.from(engine.filters.get(id1).subfilters)[0]; 192 | const sf2 = Array.from(engine.filters.get(id2).subfilters)[0]; 193 | const storage = engine.foPairs.get("geospatial"); 194 | 195 | should(storage).be.instanceOf(FieldOperand); 196 | should( 197 | storage.fields.get("foo").get(Array.from(sf1.conditions)[0].id), 198 | ).match(new Set([sf1, sf2])); 199 | }); 200 | 201 | it("should add another condition to an already existing field", () => { 202 | const id1 = koncorde.register({ geoPolygon: { foo: polygon } }); 203 | const id2 = koncorde.register({ 204 | geoBoundingBox: { 205 | foo: { 206 | bottomRight: "drj7teegpus6", 207 | topLeft: "dr5r9ydj2y73", 208 | }, 209 | }, 210 | }); 211 | 212 | const sf1 = Array.from(engine.filters.get(id1).subfilters)[0]; 213 | const cond1 = Array.from(sf1.conditions)[0].id; 214 | const sf2 = Array.from(engine.filters.get(id2).subfilters)[0]; 215 | const storage = engine.foPairs.get("geospatial"); 216 | 217 | should(storage).be.instanceOf(FieldOperand); 218 | should(storage.fields.get("foo").get(cond1)).match(new Set([sf1])); 219 | should( 220 | storage.fields.get("foo").get(Array.from(sf2.conditions)[0].id), 221 | ).match(new Set([sf2])); 222 | }); 223 | }); 224 | 225 | describe("#matching", () => { 226 | it("should match a point inside the polygon", () => { 227 | const id = koncorde.register({ geoPolygon: { foo: polygon } }); 228 | 229 | const result = koncorde.test({ 230 | foo: { 231 | latLon: [43.6073913, 3.9109057], 232 | }, 233 | }); 234 | 235 | should(result).eql([id]); 236 | }); 237 | 238 | it("should match a point exactly on a polygon corner", () => { 239 | const id = koncorde.register({ geoPolygon: { foo: polygon } }); 240 | 241 | const result = koncorde.test({ foo: { latLon: polygon.points[0] } }); 242 | 243 | should(result).eql([id]); 244 | }); 245 | 246 | it("should not match if a point is outside the bbox", () => { 247 | koncorde.register({ geoPolygon: { foo: polygon } }); 248 | const result = koncorde.test({ 249 | foo: { 250 | lat: polygon.points[0][0] + 10e-6, 251 | lon: polygon.points[0][1] + 10e-6, 252 | }, 253 | }); 254 | 255 | should(result).be.an.Array().and.be.empty(); 256 | }); 257 | 258 | it("should return an empty array if the document does not contain a geopoint", () => { 259 | koncorde.register({ geoPolygon: { foo: polygon } }); 260 | 261 | const result = koncorde.test({ bar: { latLon: polygon.points[0] } }); 262 | 263 | should(result).be.an.Array().and.be.empty(); 264 | }); 265 | 266 | it("should return an empty array if the document contain an invalid geopoint", () => { 267 | koncorde.register({ geoPolygon: { foo: polygon } }); 268 | 269 | const result = koncorde.test({ foo: "43.6331979 / 3.8433703" }); 270 | 271 | should(result).be.an.Array().and.be.empty(); 272 | }); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Kuzzle, a backend software, self-hostable and ready to use 3 | * to power modern apps 4 | * 5 | * Copyright 2015-2021 Kuzzle 6 | * mailto: support AT kuzzle.io 7 | * website: http://kuzzle.io 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * https://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | import { randomBytes } from "crypto"; 23 | 24 | import { Transformer } from "./transform"; 25 | import { Engine } from "./engine"; 26 | import { convertDistance } from "./util/convertDistance"; 27 | import { convertGeopoint } from "./util/convertGeopoint"; 28 | import { hash } from "./util/hash"; 29 | import { JSONObject } from "./types/JSONObject"; 30 | import { flattenObject } from "./util/Flatten"; 31 | 32 | /** 33 | * Describes a search filter normalized by Koncorde. 34 | * Returned by Koncorde.normalize(), and usable with Koncorde.store(). 35 | */ 36 | export class NormalizedFilter { 37 | /** 38 | * Normalized filter. 39 | * 40 | * @type {JSONObject[][]} 41 | */ 42 | public filter: JSONObject[][]; 43 | 44 | /** 45 | * Filter unique identifier. 46 | * 47 | * @type {string} 48 | */ 49 | public id: string; 50 | 51 | /** 52 | * Target index name. 53 | * 54 | * @type {string} 55 | */ 56 | public index: string; 57 | 58 | constructor(normalized: any, id: string, index: string | null) { 59 | this.filter = normalized; 60 | this.id = id; 61 | this.index = index; 62 | } 63 | } 64 | 65 | /** 66 | * Koncorde configuration 67 | */ 68 | export interface KoncordeOptions { 69 | /** 70 | * The maximum number of conditions a filter can hold. 71 | * It is advised to test performances and memory consumption 72 | * impacts before increasing this value. If set to 0, no limit is applied. 73 | * 74 | * NOTE: this check is performed after filters are decomposed, meaning that 75 | * the limit can kick in even though the provided filter is seemingly 76 | * simpler. 77 | * 78 | * (default: 50) 79 | * 80 | * @type {number} 81 | */ 82 | maxConditions: number; 83 | 84 | /** 85 | * Set the regex engine to either re2 or js. 86 | * The former is not fully compatible with PCREs, while the latter is 87 | * vulnerable to catastrophic backtracking, making it unsafe if regular 88 | * expressions are provided by end-users. 89 | * 90 | * (default: re2) 91 | * 92 | * @type {string} 93 | */ 94 | regExpEngine: string; 95 | 96 | /** 97 | * 32 bytes buffer containing a fixed random seed, to make filter 98 | * unique identifiers predictable. 99 | * 100 | * @type {ArrayBuffer} 101 | */ 102 | seed: ArrayBuffer; 103 | } 104 | 105 | export class Koncorde { 106 | private engines: Map; 107 | private config: JSONObject; 108 | private transformer: Transformer; 109 | 110 | /** 111 | * @param {Object} config */ 112 | constructor(config: KoncordeOptions = null) { 113 | if (config && (typeof config !== "object" || Array.isArray(config))) { 114 | throw new Error("Invalid argument: expected an object"); 115 | } 116 | 117 | this.config = { 118 | maxConditions: (config && config.maxConditions) || 50, 119 | regExpEngine: (config && config.regExpEngine) || "re2", 120 | seed: (config && config.seed) || randomBytes(32), 121 | }; 122 | 123 | if ( 124 | this.config.regExpEngine !== "re2" && 125 | this.config.regExpEngine !== "js" 126 | ) { 127 | throw new Error( 128 | 'Invalid configuration value for "regExpEngine". Supported: re2, js', 129 | ); 130 | } 131 | 132 | if ( 133 | !(this.config.seed instanceof Buffer) || 134 | this.config.seed.length !== 32 135 | ) { 136 | throw new Error("Invalid seed: expected a 32 bytes long Buffer"); 137 | } 138 | 139 | if ( 140 | !Number.isInteger(this.config.maxConditions) || 141 | this.config.maxConditions < 0 142 | ) { 143 | throw new Error( 144 | "Invalid maxConditions configuration: positive or nul integer expected", 145 | ); 146 | } 147 | 148 | this.transformer = new Transformer(this.config); 149 | 150 | // Indexed engines: the default index is mapped to the null key 151 | this.engines = new Map([[null, new Engine(this.config)]]); 152 | } 153 | 154 | /** 155 | * Checks if the provided filter is valid 156 | * 157 | * @param {Object} filter 158 | * @throws {KoncordeParseError} 159 | */ 160 | validate(filter: JSONObject): void { 161 | this.transformer.check(filter); 162 | } 163 | 164 | /** 165 | * Subscribes an unoptimized filter to the real-time engine. 166 | * Identical to a call to normalize() + store() 167 | * 168 | * Returns the filter unique identifier 169 | * 170 | * @param {Object} filter 171 | * @param {String} [index] - Index name 172 | * @return {String} 173 | * @throws {KoncordeParseError} 174 | */ 175 | register(filter: JSONObject, index: string = null): string { 176 | const normalized = this.normalize(filter, index); 177 | return this.store(normalized); 178 | } 179 | 180 | /** 181 | * Returns an optimized version of the provided filter, with 182 | * its associated filter unique ID. 183 | * Does not store anything in the filters structures. 184 | * The returned object can either be used with store(), or discarded. 185 | * 186 | * @param {Object} filter 187 | * @param {String} [index] name 188 | * @return {NormalizedFilter} 189 | * @throws {KoncordeParseError} 190 | */ 191 | normalize(filter: JSONObject, index: string = null): NormalizedFilter { 192 | if (index !== null && typeof index !== "string") { 193 | throw new Error('Invalid "index" argument: must be a string'); 194 | } 195 | 196 | const normalized = this.transformer.normalize(filter); 197 | const id = hash(this.config.seed, { filter: normalized, index }); 198 | 199 | return new NormalizedFilter(normalized, id, index); 200 | } 201 | 202 | /** 203 | * Stores a normalized filter. 204 | * A normalized filter is obtained using a call to normalize() 205 | * 206 | * Returns the filter unique identifer 207 | * 208 | * @param {NormalizedFilter} normalized - Obtained with a call to normalize() 209 | * @return {String} 210 | */ 211 | store(normalized: NormalizedFilter): string { 212 | if (!(normalized instanceof NormalizedFilter)) { 213 | throw new Error( 214 | "Invalid argument: not a normalized filter (use Koncorde.normalize to get one)", 215 | ); 216 | } 217 | 218 | let engine = this.engines.get(normalized.index); 219 | 220 | if (!engine) { 221 | engine = new Engine(this.config); 222 | this.engines.set(normalized.index, engine); 223 | } 224 | 225 | engine.store(normalized); 226 | 227 | return normalized.id; 228 | } 229 | 230 | /** 231 | * Returns all indexed filter IDs 232 | * 233 | * @param {String} [index] name 234 | * @returns {Array.} Array of matching filter IDs 235 | */ 236 | getFilterIds(index: string = null): string[] { 237 | const engine = this.engines.get(index); 238 | 239 | if (!engine) { 240 | return []; 241 | } 242 | 243 | return Array.from(engine.filters.keys()); 244 | } 245 | 246 | /** 247 | * Returns the list of named indexes 248 | * 249 | * @return {Array.} 250 | */ 251 | getIndexes(): string[] { 252 | return Array.from(this.engines.keys()).map((i) => i || "(default)"); 253 | } 254 | 255 | /** 256 | * Check if a filter identifier is known by Koncorde 257 | * 258 | * @param {String} filterId 259 | * @param {String} [index] name 260 | * @returns {Boolean} 261 | */ 262 | hasFilterId(filterId: string, index: string = null): boolean { 263 | const engine = this.engines.get(index); 264 | 265 | return engine && engine.filters.has(filterId); 266 | } 267 | 268 | /** 269 | * Test data against filters in the filters tree to get the matching 270 | * filters ID, if any 271 | * 272 | * @param {Object} data to test filters on 273 | * @param {String} [index] name 274 | * @return {Array} list of matching filters 275 | */ 276 | test(data: JSONObject, index: string = null): string[] { 277 | const engine = this.engines.get(index); 278 | 279 | if (!engine) { 280 | return []; 281 | } 282 | 283 | return engine.match(flattenObject(data)); 284 | } 285 | 286 | /** 287 | * Removes all references to a given filter from the real-time engine 288 | * 289 | * @param {String} filterId - ID of the filter to remove 290 | * @param {String} [index] name 291 | */ 292 | remove(filterId: string, index: string = null): void { 293 | const engine = this.engines.get(index); 294 | 295 | if (!engine) { 296 | return; 297 | } 298 | 299 | const remaining = engine.remove(filterId); 300 | 301 | if (index && remaining === 0) { 302 | this.engines.delete(index); 303 | } 304 | } 305 | 306 | /** 307 | * Converts a distance string value to a number of meters 308 | * @param {string} distance - client-provided distance 309 | * @returns {number} converted distance 310 | */ 311 | static convertDistance(distance: string): number { 312 | return convertDistance(distance); 313 | } 314 | 315 | /** 316 | * Converts one of the accepted geopoint format into 317 | * a standardized version 318 | * 319 | * @param {Object} obj - object containing a geopoint 320 | * @returns {Coordinate} or null if no accepted format is found 321 | */ 322 | static convertGeopoint(point: string | JSONObject): { 323 | lat: number; 324 | lon: number; 325 | } { 326 | return convertGeopoint(point); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /test/keywords/notgeospatial.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 6 | const { Koncorde } = require("../../"); 7 | 8 | /** 9 | * Tests not geoBoundingBox, not geoDistance, not geoDistanceRange 10 | * and not geoPolygon keywords 11 | * 12 | * Does not check filter validation nor standardization as these parts 13 | * are already tested in the normal keyword unit tests 14 | */ 15 | 16 | describe("Koncorde.keyword.notgeospatial", () => { 17 | let koncorde; 18 | let engine; 19 | 20 | beforeEach(() => { 21 | koncorde = new Koncorde(); 22 | engine = koncorde.engines.get(null); 23 | }); 24 | 25 | describe("#storage", () => { 26 | it("should store a single geospatial keyword correctly", () => { 27 | const id = koncorde.register({ 28 | not: { 29 | geoDistance: { 30 | foo: { 31 | lat: 13, 32 | lon: 42, 33 | }, 34 | distance: "1000m", 35 | }, 36 | }, 37 | }); 38 | 39 | const storage = engine.foPairs.get("notgeospatial"); 40 | const subfilter = Array.from(engine.filters.get(id).subfilters)[0]; 41 | const condId = Array.from(subfilter.conditions)[0].id; 42 | 43 | should(storage).be.instanceOf(FieldOperand); 44 | should(storage.custom.index).be.an.Object(); 45 | should(storage.fields.get("foo")).be.instanceOf(Map); 46 | should(storage.fields.get("foo").size).be.eql(1); 47 | should(storage.fields.get("foo").get(condId)).eql(new Set([subfilter])); 48 | }); 49 | 50 | it("should add another condition to an already tested field", () => { 51 | const id1 = koncorde.register({ 52 | not: { 53 | geoDistance: { 54 | foo: { 55 | lat: 13, 56 | lon: 42, 57 | }, 58 | distance: "1000m", 59 | }, 60 | }, 61 | }); 62 | 63 | const id2 = koncorde.register({ 64 | not: { 65 | geoBoundingBox: { 66 | foo: { 67 | bottom: -14, 68 | left: 0, 69 | right: 42, 70 | top: 13, 71 | }, 72 | }, 73 | }, 74 | }); 75 | 76 | const subfilter1 = Array.from(engine.filters.get(id1).subfilters)[0]; 77 | const subfilter2 = Array.from(engine.filters.get(id2).subfilters)[0]; 78 | const condId1 = Array.from(subfilter1.conditions)[0].id; 79 | const condId2 = Array.from(subfilter2.conditions)[0].id; 80 | const storage = engine.foPairs.get("notgeospatial"); 81 | 82 | should(storage).be.instanceOf(FieldOperand); 83 | should(storage.custom.index).be.an.Object(); 84 | should(storage.fields.get("foo")).be.instanceOf(Map); 85 | should(storage.fields.get("foo").size).be.eql(2); 86 | should(storage.fields.get("foo").get(condId1)).eql(new Set([subfilter1])); 87 | should(storage.fields.get("foo").get(condId2)).eql(new Set([subfilter2])); 88 | }); 89 | 90 | it("should add another subfilter to an already tested shape", () => { 91 | const filter = { 92 | not: { 93 | geoDistance: { 94 | foo: { 95 | lat: 13, 96 | lon: 42, 97 | }, 98 | distance: "1000m", 99 | }, 100 | }, 101 | }; 102 | 103 | const id1 = koncorde.register(filter); 104 | const id2 = koncorde.register({ 105 | and: [{ equals: { bar: "baz" } }, filter], 106 | }); 107 | 108 | const storage = engine.foPairs.get("notgeospatial"); 109 | const subfilter = Array.from(engine.filters.get(id1).subfilters)[0]; 110 | const subfilter2 = Array.from(engine.filters.get(id2).subfilters)[0]; 111 | const id = Array.from(subfilter.conditions)[0].id; 112 | 113 | should(storage).be.instanceOf(FieldOperand); 114 | should(storage.custom.index).be.an.Object(); 115 | should(storage.fields.get("foo")).be.instanceOf(Map); 116 | should(storage.fields.get("foo").size).be.eql(1); 117 | 118 | should(storage.fields.get("foo").get(id)).eql( 119 | new Set([subfilter, subfilter2]), 120 | ); 121 | }); 122 | }); 123 | 124 | describe("#match", () => { 125 | let distanceId; 126 | let distanceRangeId; 127 | let polygonId; 128 | 129 | beforeEach(() => { 130 | koncorde.register({ 131 | not: { 132 | geoBoundingBox: { 133 | foo: { 134 | bottom: 43.5810609, 135 | left: 3.8433703, 136 | right: 3.9282093, 137 | top: 43.6331979, 138 | }, 139 | }, 140 | }, 141 | }); 142 | 143 | distanceId = koncorde.register({ 144 | not: { 145 | geoDistance: { 146 | foo: { 147 | lat: 43.5764455, 148 | lon: 3.948711, 149 | }, 150 | distance: "2000m", 151 | }, 152 | }, 153 | }); 154 | 155 | distanceRangeId = koncorde.register({ 156 | not: { 157 | geoDistanceRange: { 158 | foo: { 159 | lat: 43.6073913, 160 | lon: 3.9109057, 161 | }, 162 | from: "10m", 163 | to: "1500m", 164 | }, 165 | }, 166 | }); 167 | 168 | polygonId = koncorde.register({ 169 | not: { 170 | geoPolygon: { 171 | foo: { 172 | points: [ 173 | { latLon: [43.6021299, 3.8989713] }, 174 | { latLon: [43.6057389, 3.8968173] }, 175 | { latLon: [43.6092889, 3.8970423] }, 176 | { latLon: [43.6100359, 3.9040853] }, 177 | { latLon: [43.6069619, 3.9170343] }, 178 | { latLon: [43.6076479, 3.9230133] }, 179 | { latLon: [43.6038779, 3.9239153] }, 180 | { latLon: [43.6019189, 3.9152403] }, 181 | { latLon: [43.6036049, 3.9092313] }, 182 | ], 183 | }, 184 | }, 185 | }, 186 | }); 187 | }); 188 | 189 | it("should match shapes not containing the provided point", () => { 190 | let result = koncorde.test({ 191 | foo: { 192 | lat: 43.6073913, 193 | lon: 3.9109057, 194 | }, 195 | }); 196 | 197 | should(result.sort()).match([distanceId, distanceRangeId].sort()); 198 | }); 199 | 200 | it("should return an empty array if the provided point is invalid", () => { 201 | should(koncorde.test({ foo: { lat: "foo", lon: "bar" } })) 202 | .be.an.Array() 203 | .and.be.empty(); 204 | }); 205 | 206 | it("should return all subscriptions if the document does not contain the registered field", () => { 207 | should(koncorde.test({ bar: { lat: 43.6073913, lon: 3.9109057 } })) 208 | .be.an.Array() 209 | .and.has.length(4); 210 | }); 211 | 212 | it("should reject a shape if the point is right on its border", () => { 213 | const result = koncorde.test({ 214 | foo: { 215 | lat: 43.5810609, 216 | lon: 3.8433703, 217 | }, 218 | }); 219 | 220 | should(result.sort()).match( 221 | [distanceId, distanceRangeId, polygonId].sort(), 222 | ); 223 | }); 224 | }); 225 | 226 | describe("#removal", () => { 227 | let filter; 228 | let filterId; 229 | let storage; 230 | 231 | beforeEach(() => { 232 | filter = { 233 | not: { 234 | geoBoundingBox: { 235 | foo: { 236 | bottom: 43.5810609, 237 | left: 3.8433703, 238 | top: 43.6331979, 239 | right: 3.9282093, 240 | }, 241 | }, 242 | }, 243 | }; 244 | 245 | filterId = koncorde.register(filter); 246 | storage = engine.foPairs.get("notgeospatial"); 247 | }); 248 | 249 | it("should destroy the whole structure when removing the last item", () => { 250 | koncorde.remove(filterId); 251 | should(engine.foPairs).be.empty(); 252 | }); 253 | 254 | it("should remove a single subfilter from a multi-filter condition", () => { 255 | const id = koncorde.register({ 256 | and: [filter, { equals: { foo: "bar" } }], 257 | }); 258 | 259 | const sf = Array.from(engine.filters.get(id).subfilters)[0]; 260 | koncorde.remove(filterId); 261 | 262 | should(storage).be.instanceOf(FieldOperand); 263 | should( 264 | storage.fields.get("foo").get(Array.from(sf.conditions)[1].id), 265 | ).match(new Set([sf])); 266 | }); 267 | 268 | it("should remove a value from the list if its last subfilter is removed", () => { 269 | const geofilter = { 270 | not: { 271 | geoDistance: { 272 | foo: { 273 | lat: 43.5764455, 274 | lon: 3.948711, 275 | }, 276 | distance: "2000m", 277 | }, 278 | }, 279 | }; 280 | 281 | const id = koncorde.register(geofilter); 282 | const subfilter = Array.from(engine.filters.get(id).subfilters)[0]; 283 | const condId = Array.from(subfilter.conditions)[0].id; 284 | 285 | koncorde.remove(filterId); 286 | 287 | should(storage).be.instanceOf(FieldOperand); 288 | should(storage.fields.get("foo").get(condId)).match(new Set([subfilter])); 289 | }); 290 | 291 | it("should remove a field from the list if its last value to test is removed", () => { 292 | const geofilter = { 293 | not: { 294 | geoDistance: { 295 | bar: { 296 | lat: 43.5764455, 297 | lon: 3.948711, 298 | }, 299 | distance: "2000m", 300 | }, 301 | }, 302 | }; 303 | 304 | const id = koncorde.register(geofilter); 305 | const subfilter = Array.from(engine.filters.get(id).subfilters)[0]; 306 | const condId = Array.from(subfilter.conditions)[0].id; 307 | const operand = engine.foPairs.get("notgeospatial"); 308 | 309 | should(operand.fields).have.keys("foo", "bar"); 310 | 311 | koncorde.remove(filterId); 312 | 313 | should(storage).be.instanceOf(FieldOperand); 314 | should(storage.fields.get("bar").get(condId)).match(new Set([subfilter])); 315 | }); 316 | }); 317 | }); 318 | -------------------------------------------------------------------------------- /test/keywords/notrange.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const should = require("should/as-function"); 4 | 5 | const FieldOperand = require("../../lib/engine/objects/fieldOperand"); 6 | const { Koncorde } = require("../../"); 7 | const { RangeCondition } = require("../../lib/engine/objects/rangeCondition"); 8 | 9 | describe("Koncorde.keyword.notrange", () => { 10 | let koncorde; 11 | let engine; 12 | 13 | beforeEach(() => { 14 | koncorde = new Koncorde(); 15 | engine = koncorde.engines.get(null); 16 | }); 17 | 18 | describe("#storage", () => { 19 | it("should store a single condition correctly", () => { 20 | const id = koncorde.register({ 21 | not: { 22 | range: { 23 | foo: { 24 | gt: 42, 25 | lt: 100, 26 | }, 27 | }, 28 | }, 29 | }); 30 | const subfilter = Array.from(engine.filters.get(id).subfilters)[0]; 31 | const store = engine.foPairs.get("notrange"); 32 | 33 | should(store).be.instanceOf(FieldOperand); 34 | should(store.fields.get("foo").conditions.size).be.eql(1); 35 | 36 | const rangeCondition = Array.from( 37 | store.fields.get("foo").conditions.values(), 38 | )[0]; 39 | should(rangeCondition).instanceOf(RangeCondition); 40 | should(rangeCondition.subfilters).eql(new Set([subfilter])); 41 | should(rangeCondition.low).approximately(42, 1e-9); 42 | should(rangeCondition.high).approximately(100, 1e-9); 43 | should(store.fields.get("foo").tree).be.an.Object(); 44 | }); 45 | 46 | it("should store multiple conditions on the same field correctly", () => { 47 | const id1 = koncorde.register({ 48 | not: { 49 | range: { 50 | foo: { 51 | gt: 42, 52 | lt: 100, 53 | }, 54 | }, 55 | }, 56 | }); 57 | 58 | const id2 = koncorde.register({ 59 | and: [ 60 | { not: { range: { foo: { gte: 10, lte: 78 } } } }, 61 | { not: { range: { foo: { gt: 0, lt: 50 } } } }, 62 | ], 63 | }); 64 | 65 | const sf1 = Array.from(engine.filters.get(id1).subfilters)[0]; 66 | const sf2 = Array.from(engine.filters.get(id2).subfilters)[0]; 67 | const store = engine.foPairs.get("notrange"); 68 | 69 | should(store).be.instanceOf(FieldOperand); 70 | should(store.fields.get("foo").conditions.size).be.eql(3); 71 | 72 | const cd1 = store.fields 73 | .get("foo") 74 | .conditions.get(Array.from(sf1.conditions)[0].id); 75 | 76 | should(cd1).instanceOf(RangeCondition); 77 | should(cd1.subfilters).eql(new Set([sf1])); 78 | should(cd1.low).exactly(42); 79 | should(cd1.high).exactly(100); 80 | 81 | const cd2 = store.fields 82 | .get("foo") 83 | .conditions.get(Array.from(sf2.conditions)[0].id); 84 | 85 | should(cd2).instanceOf(RangeCondition); 86 | should(cd2.subfilters).eql(new Set([sf2])); 87 | should(cd2.low).approximately(10, 1e-9); 88 | should(cd2.high).approximately(78, 1e-9); 89 | 90 | const cd3 = store.fields 91 | .get("foo") 92 | .conditions.get(Array.from(sf2.conditions)[1].id); 93 | 94 | should(cd3).instanceOf(RangeCondition); 95 | should(cd3.subfilters).eql(new Set([sf2])); 96 | should(cd3.low).exactly(0); 97 | should(cd3.high).exactly(50); 98 | 99 | should(store.fields.get("foo").tree).be.an.Object(); 100 | }); 101 | }); 102 | 103 | describe("#matching", () => { 104 | it("should match a document with its value outside the range", () => { 105 | const id = koncorde.register({ 106 | not: { 107 | range: { 108 | foo: { 109 | gt: 42, 110 | lt: 110, 111 | }, 112 | }, 113 | }, 114 | }); 115 | 116 | should(koncorde.test({ foo: -89 })).eql([id]); 117 | }); 118 | 119 | it("should match a document with its value exactly on the lower exclusive boundary", () => { 120 | const id = koncorde.register({ 121 | not: { 122 | range: { 123 | foo: { 124 | gt: 42, 125 | lt: 110, 126 | }, 127 | }, 128 | }, 129 | }); 130 | 131 | should(koncorde.test({ foo: 42 })).eql([id]); 132 | }); 133 | 134 | it("should match a document with its value exactly on the upper exclusive boundary", () => { 135 | const id = koncorde.register({ 136 | not: { 137 | range: { 138 | foo: { 139 | gt: 42, 140 | lt: 110, 141 | }, 142 | }, 143 | }, 144 | }); 145 | 146 | should(koncorde.test({ foo: 110 })).be.eql([id]); 147 | }); 148 | 149 | it("should not match a document with its value exactly on the lower inclusive boundary", () => { 150 | koncorde.register({ 151 | not: { 152 | range: { 153 | foo: { 154 | gte: 42, 155 | lt: 110, 156 | }, 157 | }, 158 | }, 159 | }); 160 | 161 | should(koncorde.test({ foo: 42 })) 162 | .be.an.Array() 163 | .and.be.empty(); 164 | }); 165 | 166 | it("should not match a document with its value exactly on the upper inclusive boundary", () => { 167 | koncorde.register({ 168 | not: { 169 | range: { 170 | foo: { 171 | gt: 42, 172 | lte: 110, 173 | }, 174 | }, 175 | }, 176 | }); 177 | 178 | should(koncorde.test({ foo: 110 })) 179 | .be.an.Array() 180 | .and.be.empty(); 181 | }); 182 | 183 | it("should match a document with only a lower boundary range", () => { 184 | const id = koncorde.register({ 185 | not: { 186 | range: { 187 | foo: { gt: -10 }, 188 | }, 189 | }, 190 | }); 191 | 192 | should(koncorde.test({ foo: -25 })).be.eql([id]); 193 | }); 194 | 195 | it("should match a document with only an upper boundary range", () => { 196 | const id = koncorde.register({ 197 | not: { 198 | range: { 199 | foo: { lt: -10 }, 200 | }, 201 | }, 202 | }); 203 | 204 | should(koncorde.test({ foo: 105 })).be.eql([id]); 205 | }); 206 | 207 | it("should return all notrange filters attached to the field if the document does not contain the registered field", () => { 208 | koncorde.register({ 209 | not: { 210 | range: { 211 | foo: { lt: -10 }, 212 | }, 213 | }, 214 | }); 215 | 216 | koncorde.register({ 217 | not: { 218 | range: { 219 | foo: { gt: 42 }, 220 | }, 221 | }, 222 | }); 223 | 224 | koncorde.register({ 225 | not: { 226 | range: { 227 | foo: { 228 | gte: -20, 229 | lt: 9999999, 230 | }, 231 | }, 232 | }, 233 | }); 234 | 235 | should(koncorde.test({ bar: 105 })) 236 | .be.an.Array() 237 | .length(3); 238 | }); 239 | 240 | it("should return all notrange filters attached to the field if the document searched field is not a number", () => { 241 | const id = koncorde.register({ 242 | not: { 243 | range: { 244 | foo: { lt: -10 }, 245 | }, 246 | }, 247 | }); 248 | 249 | should(koncorde.test({ bar: "baz" })).be.eql([id]); 250 | }); 251 | }); 252 | 253 | describe("#removal", () => { 254 | it("should destroy the whole structure when removing the last item", () => { 255 | const id = koncorde.register({ 256 | not: { 257 | range: { 258 | foo: { 259 | gte: 42, 260 | lte: 110, 261 | }, 262 | }, 263 | }, 264 | }); 265 | 266 | koncorde.remove(id); 267 | should(engine.foPairs).be.empty(); 268 | }); 269 | 270 | it("should remove a single subfilter from a multi-filter condition", () => { 271 | const id1 = koncorde.register({ 272 | not: { 273 | range: { 274 | foo: { 275 | gte: 42, 276 | lte: 110, 277 | }, 278 | }, 279 | }, 280 | }); 281 | 282 | const id2 = koncorde.register({ 283 | and: [ 284 | { not: { range: { foo: { lt: 50 } } } }, 285 | { not: { range: { foo: { gt: 2 } } } }, 286 | { not: { range: { foo: { gte: 42, lte: 110 } } } }, 287 | ], 288 | }); 289 | 290 | const storage = engine.foPairs.get("notrange"); 291 | 292 | should(storage.fields.get("foo").conditions.size).eql(3); 293 | 294 | koncorde.remove(id2); 295 | 296 | should(storage).be.instanceOf(FieldOperand); 297 | should(storage.fields.get("foo").conditions.size).eql(1); 298 | 299 | const multiSubfilter = Array.from(engine.filters.get(id1).subfilters)[0]; 300 | const rcd = storage.fields 301 | .get("foo") 302 | .conditions.get(Array.from(multiSubfilter.conditions)[0].id); 303 | 304 | should(rcd).instanceOf(RangeCondition); 305 | should(rcd.subfilters).match(new Set([multiSubfilter])); 306 | should(rcd.low).approximately(42, 1e-9); 307 | should(rcd.high).approximately(110, 1e-9); 308 | }); 309 | 310 | it("should remove a field from the list if its last subfilter is removed", () => { 311 | const id1 = koncorde.register({ 312 | not: { 313 | range: { 314 | bar: { 315 | gt: 42, 316 | lt: 110, 317 | }, 318 | }, 319 | }, 320 | }); 321 | 322 | const id2 = koncorde.register({ 323 | not: { 324 | range: { 325 | foo: { 326 | gt: 42, 327 | lt: 110, 328 | }, 329 | }, 330 | }, 331 | }); 332 | 333 | const storage = engine.foPairs.get("notrange"); 334 | 335 | should(storage.fields).have.keys("bar", "foo"); 336 | 337 | koncorde.remove(id1); 338 | 339 | should(storage).be.instanceOf(FieldOperand); 340 | should(storage.fields.get("foo").conditions.size).eql(1); 341 | 342 | const multiSubfilter = Array.from(engine.filters.get(id2).subfilters)[0]; 343 | const rcd = storage.fields 344 | .get("foo") 345 | .conditions.get(Array.from(multiSubfilter.conditions)[0].id); 346 | 347 | should(rcd).instanceOf(RangeCondition); 348 | should(rcd.subfilters).match(new Set([multiSubfilter])); 349 | should(rcd.low).eql(42); 350 | should(rcd.high).eql(110); 351 | should(storage.fields.get("bar")).be.undefined(); 352 | }); 353 | }); 354 | }); 355 | --------------------------------------------------------------------------------