Merge pull request #3216 from omnivore-app/fix/non-english-word-in-search
capture non-english words in parser
This commit is contained in:
@ -15,6 +15,7 @@ COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
|
||||
COPY /packages/api/package.json ./packages/api/package.json
|
||||
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
|
||||
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
|
||||
COPY /packages/liqe/package.json ./packages/liqe/package.json
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
|
||||
@ -22,9 +23,11 @@ ADD /packages/readabilityjs ./packages/readabilityjs
|
||||
ADD /packages/api ./packages/api
|
||||
ADD /packages/text-to-speech ./packages/text-to-speech
|
||||
ADD /packages/content-handler ./packages/content-handler
|
||||
ADD /packages/liqe ./packages/liqe
|
||||
|
||||
RUN yarn workspace @omnivore/text-to-speech-handler build
|
||||
RUN yarn workspace @omnivore/content-handler build
|
||||
RUN yarn workspace @omnivore/liqe build
|
||||
RUN yarn workspace @omnivore/api build
|
||||
|
||||
# After building, fetch the production dependencies
|
||||
@ -50,6 +53,7 @@ COPY --from=builder /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/packages/text-to-speech/ /app/packages/text-to-speech/
|
||||
COPY --from=builder /app/packages/content-handler/ /app/packages/content-handler/
|
||||
COPY --from=builder /app/packages/liqe/ /app/packages/liqe/
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["yarn", "workspace", "@omnivore/api", "start"]
|
||||
|
||||
@ -10,6 +10,7 @@ COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
|
||||
COPY /packages/api/package.json ./packages/api/package.json
|
||||
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
|
||||
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
|
||||
COPY /packages/liqe/package.json ./packages/liqe/package.json
|
||||
|
||||
RUN apk --no-cache --virtual build-dependencies add \
|
||||
python3 \
|
||||
@ -24,5 +25,6 @@ COPY /packages/readabilityjs ./packages/readabilityjs
|
||||
COPY /packages/api ./packages/api
|
||||
COPY /packages/text-to-speech ./packages/text-to-speech
|
||||
COPY /packages/content-handler ./packages/content-handler
|
||||
COPY /packages/liqe ./packages/liqe
|
||||
|
||||
CMD ["yarn", "workspace", "@omnivore/api", "test"]
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@google-cloud/tasks": "^4.0.0",
|
||||
"@graphql-tools/utils": "^9.1.1",
|
||||
"@omnivore/content-handler": "1.0.0",
|
||||
"@omnivore/liqe": "1.0.0",
|
||||
"@omnivore/readability": "1.0.0",
|
||||
"@omnivore/text-to-speech-handler": "1.0.0",
|
||||
"@opentelemetry/api": "^1.0.1",
|
||||
@ -71,7 +72,6 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "^2.0.3",
|
||||
"linkedom": "^0.14.9",
|
||||
"liqe": "^3.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { LiqeQuery } from 'liqe'
|
||||
import { LiqeQuery } from '@omnivore/liqe'
|
||||
import { DateTime } from 'luxon'
|
||||
import { DeepPartial, ObjectLiteral } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { LiqeQuery, parse } from 'liqe'
|
||||
import { LiqeQuery, parse } from '@omnivore/liqe'
|
||||
|
||||
export const parseSearchQuery = (query: string): LiqeQuery => {
|
||||
const searchQuery = query
|
||||
|
||||
1
packages/liqe/.eslintignore
Normal file
1
packages/liqe/.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
src/grammar.ts
|
||||
14
packages/liqe/.eslintrc
Normal file
14
packages/liqe/.eslintrc
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": [
|
||||
"canonical",
|
||||
"canonical/node",
|
||||
"canonical/typescript"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"root": true,
|
||||
"rules": {
|
||||
"@typescript-eslint/no-parameter-properties": 0
|
||||
}
|
||||
}
|
||||
19
packages/liqe/.gitignore
vendored
Executable file
19
packages/liqe/.gitignore
vendored
Executable file
@ -0,0 +1,19 @@
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
||||
.*
|
||||
!.editorconfig
|
||||
!.eslintignore
|
||||
!.eslintrc
|
||||
!.flowconfig
|
||||
!.github
|
||||
!.gitignore
|
||||
!.husky
|
||||
!.ncurc.js
|
||||
!.npmignore
|
||||
!.npmrc
|
||||
!.nycrc
|
||||
!.README
|
||||
!.releaserc
|
||||
!.travis.yml
|
||||
6
packages/liqe/.npmignore
Normal file
6
packages/liqe/.npmignore
Normal file
@ -0,0 +1,6 @@
|
||||
/src
|
||||
/test
|
||||
/coverage
|
||||
/benchmark
|
||||
.*
|
||||
*.log
|
||||
1
packages/liqe/.npmrc
Normal file
1
packages/liqe/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
package-lock=false
|
||||
4
packages/liqe/.nycrc
Normal file
4
packages/liqe/.nycrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@istanbuljs/nyc-config-typescript",
|
||||
"all": true
|
||||
}
|
||||
24
packages/liqe/LICENSE
Normal file
24
packages/liqe/LICENSE
Normal file
@ -0,0 +1,24 @@
|
||||
Copyright (c) 2021, Gajus Kuizinas (http://gajus.com/)
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the
|
||||
names of its contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL GAJUS KUIZINAS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
431
packages/liqe/README.md
Normal file
431
packages/liqe/README.md
Normal file
@ -0,0 +1,431 @@
|
||||
# liqe
|
||||
|
||||
Lightweight and performant Lucene-like parser, serializer and search engine.
|
||||
|
||||
- [liqe](#liqe)
|
||||
- [Motivation](#motivation)
|
||||
- [Usage](#usage)
|
||||
- [Query Syntax](#query-syntax)
|
||||
- [Liqe syntax cheat sheet](#liqe-syntax-cheat-sheet)
|
||||
- [Keyword matching](#keyword-matching)
|
||||
- [Number matching](#number-matching)
|
||||
- [Range matching](#range-matching)
|
||||
- [Wildcard matching](#wildcard-matching)
|
||||
- [Boolean operators](#boolean-operators)
|
||||
- [Serializer](#serializer)
|
||||
- [AST](#ast)
|
||||
- [Utilities](#utilities)
|
||||
- [Compatibility with Lucene](#compatibility-with-lucene)
|
||||
- [Recipes](#recipes)
|
||||
- [Handling syntax errors](#handling-syntax-errors)
|
||||
- [Highlighting matches](#highlighting-matches)
|
||||
- [Development](#development)
|
||||
- [Compiling Parser](#compiling-parser)
|
||||
- [Benchmarking Changes](#benchmarking-changes)
|
||||
- [Tutorials](#tutorials)
|
||||
|
||||
## Motivation
|
||||
|
||||
Originally built Liqe to enable [Roarr](https://github.com/gajus/roarr) log filtering via [cli](https://github.com/gajus/roarr-cli#filtering-logs). I have since been polishing this project as a hobby/intellectual exercise. I've seen it being adopted by [various](https://github.com/gajus/liqe/network/dependents) CLI and web applications that require advanced search. To my knowledge, it is currently the most complete Lucene-like syntax parser and serializer in JavaScript, as well as a compatible in-memory search engine.
|
||||
|
||||
Liqe use cases include:
|
||||
|
||||
* parsing search queries
|
||||
* serializing parsed queries
|
||||
* searching JSON documents using the Liqe query language (LQL)
|
||||
|
||||
Note that the [Liqe AST](#ast) is treated as a public API, i.e., one could implement their own search mechanism that uses Liqe query language (LQL).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
filter,
|
||||
highlight,
|
||||
parse,
|
||||
test,
|
||||
} from 'liqe';
|
||||
|
||||
const persons = [
|
||||
{
|
||||
height: 180,
|
||||
name: 'John Morton',
|
||||
},
|
||||
{
|
||||
height: 175,
|
||||
name: 'David Barker',
|
||||
},
|
||||
{
|
||||
height: 170,
|
||||
name: 'Thomas Castro',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Filter a collection:
|
||||
|
||||
```ts
|
||||
filter(parse('height:>170'), persons);
|
||||
// [
|
||||
// {
|
||||
// height: 180,
|
||||
// name: 'John Morton',
|
||||
// },
|
||||
// {
|
||||
// height: 175,
|
||||
// name: 'David Barker',
|
||||
// },
|
||||
// ]
|
||||
```
|
||||
|
||||
Test a single object:
|
||||
|
||||
```ts
|
||||
test(parse('name:John'), persons[0]);
|
||||
// true
|
||||
test(parse('name:David'), persons[0]);
|
||||
// false
|
||||
```
|
||||
|
||||
Highlight matching fields and substrings:
|
||||
|
||||
```ts
|
||||
test(highlight('name:john'), persons[0]);
|
||||
// [
|
||||
// {
|
||||
// path: 'name',
|
||||
// query: /(John)/,
|
||||
// }
|
||||
// ]
|
||||
test(highlight('height:180'), persons[0]);
|
||||
// [
|
||||
// {
|
||||
// path: 'height',
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
## Query Syntax
|
||||
|
||||
Liqe uses Liqe Query Language (LQL), which is heavily inspired by Lucene but extends it in various ways that allow a more powerful search experience.
|
||||
|
||||
### Liqe syntax cheat sheet
|
||||
|
||||
```rb
|
||||
# search for "foo" term anywhere in the document (case insensitive)
|
||||
foo
|
||||
|
||||
# search for "foo" term anywhere in the document (case sensitive)
|
||||
'foo'
|
||||
"foo"
|
||||
|
||||
# search for "foo" term in `name` field
|
||||
name:foo
|
||||
|
||||
# search for "foo" term in `full name` field
|
||||
'full name':foo
|
||||
"full name":foo
|
||||
|
||||
# search for "foo" term in `first` field, member of `name`, i.e.
|
||||
# matches {name: {first: 'foo'}}
|
||||
name.first:foo
|
||||
|
||||
# search using regex
|
||||
name:/foo/
|
||||
name:/foo/o
|
||||
|
||||
# search using wildcard
|
||||
name:foo*bar
|
||||
name:foo?bar
|
||||
|
||||
# boolean search
|
||||
member:true
|
||||
member:false
|
||||
|
||||
# null search
|
||||
member:null
|
||||
|
||||
# search for age =, >, >=, <, <=
|
||||
height:=100
|
||||
height:>100
|
||||
height:>=100
|
||||
height:<100
|
||||
height:<=100
|
||||
|
||||
# search for height in range (inclusive, exclusive)
|
||||
height:[100 TO 200]
|
||||
height:{100 TO 200}
|
||||
|
||||
# boolean operators
|
||||
name:foo AND height:=100
|
||||
name:foo OR name:bar
|
||||
|
||||
# unary operators
|
||||
NOT foo
|
||||
-foo
|
||||
NOT foo:bar
|
||||
-foo:bar
|
||||
name:foo AND NOT (bio:bar OR bio:baz)
|
||||
|
||||
# implicit AND boolean operator
|
||||
name:foo height:=100
|
||||
|
||||
# grouping
|
||||
name:foo AND (bio:bar OR bio:baz)
|
||||
```
|
||||
|
||||
### Keyword matching
|
||||
|
||||
Search for word "foo" in any field (case insensitive).
|
||||
|
||||
```rb
|
||||
foo
|
||||
```
|
||||
|
||||
Search for word "foo" in the `name` field.
|
||||
|
||||
```rb
|
||||
name:foo
|
||||
```
|
||||
|
||||
Search for `name` field values matching `/foo/i` regex.
|
||||
|
||||
```rb
|
||||
name:/foo/i
|
||||
```
|
||||
|
||||
Search for `name` field values matching `f*o` wildcard pattern.
|
||||
|
||||
```rb
|
||||
name:f*o
|
||||
```
|
||||
|
||||
Search for `name` field values matching `f?o` wildcard pattern.
|
||||
|
||||
```rb
|
||||
name:f?o
|
||||
```
|
||||
|
||||
Search for phrase "foo bar" in the `name` field (case sensitive).
|
||||
|
||||
```rb
|
||||
name:"foo bar"
|
||||
```
|
||||
|
||||
### Number matching
|
||||
|
||||
Search for value equal to 100 in the `height` field.
|
||||
|
||||
```rb
|
||||
height:=100
|
||||
```
|
||||
|
||||
Search for value greater than 100 in the `height` field.
|
||||
|
||||
```rb
|
||||
height:>100
|
||||
```
|
||||
|
||||
Search for value greater than or equal to 100 in the `height` field.
|
||||
|
||||
```rb
|
||||
height:>=100
|
||||
```
|
||||
|
||||
### Range matching
|
||||
|
||||
Search for value greater or equal to 100 and lower or equal to 200 in the `height` field.
|
||||
|
||||
```rb
|
||||
height:[100 TO 200]
|
||||
```
|
||||
|
||||
Search for value greater than 100 and lower than 200 in the `height` field.
|
||||
|
||||
```rb
|
||||
height:{100 TO 200}
|
||||
```
|
||||
|
||||
### Wildcard matching
|
||||
|
||||
Search for any word that starts with "foo" in the `name` field.
|
||||
|
||||
```rb
|
||||
name:foo*
|
||||
```
|
||||
|
||||
Search for any word that starts with "foo" and ends with "bar" in the `name` field.
|
||||
|
||||
```rb
|
||||
name:foo*bar
|
||||
```
|
||||
|
||||
Search for any word that starts with "foo" in the `name` field, followed by a single arbitrary character.
|
||||
|
||||
```rb
|
||||
name:foo?
|
||||
```
|
||||
|
||||
Search for any word that starts with "foo", followed by a single arbitrary character and immediately ends with "bar" in the `name` field.
|
||||
|
||||
```rb
|
||||
name:foo?bar
|
||||
```
|
||||
|
||||
### Boolean operators
|
||||
|
||||
Search for phrase "foo bar" in the `name` field AND the phrase "quick fox" in the `bio` field.
|
||||
|
||||
```rb
|
||||
name:"foo bar" AND bio:"quick fox"
|
||||
```
|
||||
|
||||
Search for either the phrase "foo bar" in the `name` field AND the phrase "quick fox" in the `bio` field, or the word "fox" in the `name` field.
|
||||
|
||||
```rb
|
||||
(name:"foo bar" AND bio:"quick fox") OR name:fox
|
||||
```
|
||||
|
||||
## Serializer
|
||||
|
||||
Serializer allows to convert Liqe tokens back to the original search query.
|
||||
|
||||
```ts
|
||||
import {
|
||||
parse,
|
||||
serialize,
|
||||
} from 'liqe';
|
||||
|
||||
const tokens = parse('foo:bar');
|
||||
|
||||
// {
|
||||
// expression: {
|
||||
// location: {
|
||||
// start: 4,
|
||||
// },
|
||||
// quoted: false,
|
||||
// type: 'LiteralExpression',
|
||||
// value: 'bar',
|
||||
// },
|
||||
// field: {
|
||||
// location: {
|
||||
// start: 0,
|
||||
// },
|
||||
// name: 'foo',
|
||||
// path: ['foo'],
|
||||
// quoted: false,
|
||||
// type: 'Field',
|
||||
// },
|
||||
// location: {
|
||||
// start: 0,
|
||||
// },
|
||||
// operator: {
|
||||
// location: {
|
||||
// start: 3,
|
||||
// },
|
||||
// operator: ':',
|
||||
// type: 'ComparisonOperator',
|
||||
// },
|
||||
// type: 'Tag',
|
||||
// }
|
||||
|
||||
serialize(tokens);
|
||||
// 'foo:bar'
|
||||
```
|
||||
|
||||
## AST
|
||||
|
||||
```ts
|
||||
import {
|
||||
type BooleanOperatorToken,
|
||||
type ComparisonOperatorToken,
|
||||
type EmptyExpression,
|
||||
type FieldToken,
|
||||
type ImplicitBooleanOperatorToken,
|
||||
type ImplicitFieldToken,
|
||||
type LiteralExpressionToken,
|
||||
type LogicalExpressionToken,
|
||||
type RangeExpressionToken,
|
||||
type RegexExpressionToken,
|
||||
type TagToken,
|
||||
type UnaryOperatorToken,
|
||||
} from 'liqe';
|
||||
```
|
||||
|
||||
There are 11 AST tokens that describe a parsed Liqe query.
|
||||
|
||||
If you are building a serializer, then you must implement all of them for the complete coverage of all possible query inputs. Refer to the [built-in serializer](./src/serialize.ts) for an example.
|
||||
|
||||
## Utilities
|
||||
|
||||
```ts
|
||||
import {
|
||||
isSafeUnquotedExpression,
|
||||
} from 'liqe';
|
||||
|
||||
/**
|
||||
* Determines if an expression requires quotes.
|
||||
* Use this if you need to programmatically manipulate the AST
|
||||
* before using a serializer to convert the query back to text.
|
||||
*/
|
||||
isSafeUnquotedExpression(expression: string): boolean;
|
||||
```
|
||||
|
||||
## Compatibility with Lucene
|
||||
|
||||
The following Lucene abilities are not supported:
|
||||
|
||||
* [Fuzzy Searches](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Fuzzy%20Searches)
|
||||
* [Proximity Searches](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Proximity%20Searches)
|
||||
* [Boosting a Term](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Boosting%20a%20Term)
|
||||
|
||||
## Recipes
|
||||
|
||||
### Handling syntax errors
|
||||
|
||||
In case of a syntax error, Liqe throws `SyntaxError`.
|
||||
|
||||
```ts
|
||||
import {
|
||||
parse,
|
||||
SyntaxError,
|
||||
} from 'liqe';
|
||||
|
||||
try {
|
||||
parse('foo bar');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.error({
|
||||
// Syntax error at line 1 column 5
|
||||
message: error.message,
|
||||
// 4
|
||||
offset: error.offset,
|
||||
// 1
|
||||
offset: error.line,
|
||||
// 5
|
||||
offset: error.column,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Highlighting matches
|
||||
|
||||
Consider using [`highlight-words`](https://github.com/tricinel/highlight-words) package to highlight Liqe matches.
|
||||
|
||||
## Development
|
||||
|
||||
### Compiling Parser
|
||||
|
||||
If you are going to modify parser, then use `npm run watch` to run compiler in watch mode.
|
||||
|
||||
### Benchmarking Changes
|
||||
|
||||
Before making any changes, capture the current benchmark on your machine using `npm run benchmark`. Run benchmark again after making any changes. Before committing changes, ensure that performance is not negatively impacted.
|
||||
|
||||
|
||||
## Tutorials
|
||||
|
||||
* [Building advanced SQL search from a user text input](https://contra.com/p/WobOBob7-building-advanced-sql-search-from-a-user-text-input)
|
||||
70
packages/liqe/package.json
Normal file
70
packages/liqe/package.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@omnivore/liqe",
|
||||
"ava": {
|
||||
"extensions": [
|
||||
"ts"
|
||||
],
|
||||
"files": [
|
||||
"test/liqe/**/*"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register/transpile-only"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"nearley": "^2.20.1",
|
||||
"ts-error": "^1.0.6"
|
||||
},
|
||||
"description": "Lightweight and performant Lucene-like parser, serializer and search engine.",
|
||||
"devDependencies": {
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@semantic-release/commit-analyzer": "^9.0.2",
|
||||
"@semantic-release/github": "^8.0.7",
|
||||
"@semantic-release/npm": "^9.0.2",
|
||||
"@types/node": "^16.10.9",
|
||||
"@types/semver-compare": "^1.0.1",
|
||||
"@types/sinon": "^10.0.4",
|
||||
"ava": "4.3.3",
|
||||
"benny": "^3.7.1",
|
||||
"coveralls": "^3.1.1",
|
||||
"del-cli": "^4.0.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-canonical": "^28.0.0",
|
||||
"faker": "^5.5.3",
|
||||
"husky": "^7.0.4",
|
||||
"npm-watch": "^0.11.0",
|
||||
"nyc": "^15.1.0",
|
||||
"semantic-release": "^20.1.0",
|
||||
"ts-node": "^10.4.0",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
},
|
||||
"keywords": [
|
||||
"lucene"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"main": "./dist/src/Liqe.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:gajus/liqe.git"
|
||||
},
|
||||
"watch": {
|
||||
"compile-parser": "src/grammar.ne"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "npm-watch",
|
||||
"benchmark": "ts-node --transpile-only test/benchmark.ts",
|
||||
"build": "del-cli ./dist && tsc",
|
||||
"compile-parser": "nearleyc src/grammar.ne --out ./src/grammar.ts && sed -i '' 's/loc?: number/loc: number/g' src/grammar.ts",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint ./src ./test && tsc --noEmit",
|
||||
"test": "NODE_ENV=test ava --serial --verbose"
|
||||
},
|
||||
"typings": "./dist/src/Liqe.d.ts",
|
||||
"version": "1.0.0",
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
41
packages/liqe/src/Liqe.ts
Normal file
41
packages/liqe/src/Liqe.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export {
|
||||
filter,
|
||||
} from './filter';
|
||||
export {
|
||||
highlight,
|
||||
} from './highlight';
|
||||
export {
|
||||
parse,
|
||||
} from './parse';
|
||||
export {
|
||||
test,
|
||||
} from './test';
|
||||
export {
|
||||
BooleanOperatorToken,
|
||||
ComparisonOperatorToken,
|
||||
EmptyExpression,
|
||||
ExpressionToken,
|
||||
FieldToken,
|
||||
Highlight,
|
||||
ImplicitBooleanOperatorToken,
|
||||
ImplicitFieldToken,
|
||||
LiqeQuery,
|
||||
LiteralExpressionToken,
|
||||
LogicalExpressionToken,
|
||||
ParenthesizedExpressionToken,
|
||||
ParserAst,
|
||||
RangeExpressionToken,
|
||||
RegexExpressionToken,
|
||||
TagToken,
|
||||
UnaryOperatorToken,
|
||||
} from './types';
|
||||
export {
|
||||
LiqeError,
|
||||
SyntaxError,
|
||||
} from './errors';
|
||||
export {
|
||||
serialize,
|
||||
} from './serialize';
|
||||
export {
|
||||
isSafeUnquotedExpression,
|
||||
} from './isSafeUnquotedExpression';
|
||||
10
packages/liqe/src/convertWildcardToRegex.ts
Normal file
10
packages/liqe/src/convertWildcardToRegex.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const WILDCARD_RULE = /(\*+)|(\?)/g;
|
||||
|
||||
export const convertWildcardToRegex = (pattern: string): RegExp => {
|
||||
return new RegExp(
|
||||
pattern
|
||||
.replace(WILDCARD_RULE, (_match, p1) => {
|
||||
return p1 ? '(.+?)' : '(.)';
|
||||
}),
|
||||
);
|
||||
};
|
||||
15
packages/liqe/src/createGetValueFunctionBody.ts
Normal file
15
packages/liqe/src/createGetValueFunctionBody.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {
|
||||
isSafePath,
|
||||
} from './isSafePath';
|
||||
|
||||
export const createGetValueFunctionBody = (path: string): string => {
|
||||
if (!isSafePath(path)) {
|
||||
throw new Error('Unsafe path.');
|
||||
}
|
||||
|
||||
const body = 'return subject' + path;
|
||||
|
||||
return body
|
||||
.replace(/(\.(\d+))/g, '.[$2]')
|
||||
.replace(/\./g, '?.');
|
||||
};
|
||||
58
packages/liqe/src/createStringTest.ts
Normal file
58
packages/liqe/src/createStringTest.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
convertWildcardToRegex,
|
||||
} from './convertWildcardToRegex';
|
||||
import {
|
||||
escapeRegexString,
|
||||
} from './escapeRegexString';
|
||||
import {
|
||||
parseRegex,
|
||||
} from './parseRegex';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
} from './types';
|
||||
|
||||
type RegExpCache = Record<string, RegExp>;
|
||||
|
||||
const createRegexTest = (regexCache: RegExpCache, regex: string) => {
|
||||
let rule: RegExp;
|
||||
|
||||
if (regexCache[regex]) {
|
||||
rule = regexCache[regex];
|
||||
} else {
|
||||
rule = regexCache[regex] = parseRegex(regex);
|
||||
}
|
||||
|
||||
return (subject: string): string | false => {
|
||||
return subject.match(rule)?.[0] ?? false;
|
||||
};
|
||||
};
|
||||
|
||||
export const createStringTest = (regexCache: RegExpCache, ast: LiqeQuery) => {
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
const {
|
||||
expression,
|
||||
} = ast;
|
||||
|
||||
if (expression.type === 'RangeExpression') {
|
||||
throw new Error('Unexpected range expression.');
|
||||
}
|
||||
|
||||
if (expression.type === 'RegexExpression') {
|
||||
return createRegexTest(regexCache, expression.value);
|
||||
}
|
||||
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.');
|
||||
}
|
||||
|
||||
const value = String(expression.value);
|
||||
|
||||
if ((value.includes('*') || value.includes('?')) && expression.quoted === false) {
|
||||
return createRegexTest(regexCache, String(convertWildcardToRegex(value)) + 'ui');
|
||||
} else {
|
||||
return createRegexTest(regexCache, '/(' + escapeRegexString(value) + ')/' + (expression.quoted ? 'u' : 'ui'));
|
||||
}
|
||||
};
|
||||
18
packages/liqe/src/errors.ts
Normal file
18
packages/liqe/src/errors.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/* eslint-disable fp/no-class */
|
||||
|
||||
import {
|
||||
ExtendableError,
|
||||
} from 'ts-error';
|
||||
|
||||
export class LiqeError extends ExtendableError {}
|
||||
|
||||
export class SyntaxError extends LiqeError {
|
||||
public constructor (
|
||||
public message: string,
|
||||
public offset: number,
|
||||
public line: number,
|
||||
public column: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
8
packages/liqe/src/escapeRegexString.ts
Normal file
8
packages/liqe/src/escapeRegexString.ts
Normal file
@ -0,0 +1,8 @@
|
||||
const ESCAPE_RULE = /[$()*+.?[\\\]^{|}]/g;
|
||||
const DASH_RULE = /-/g;
|
||||
|
||||
export const escapeRegexString = (subject: string): string => {
|
||||
return subject
|
||||
.replace(ESCAPE_RULE, '\\$&')
|
||||
.replace(DASH_RULE, '\\x2d');
|
||||
};
|
||||
16
packages/liqe/src/filter.ts
Normal file
16
packages/liqe/src/filter.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {
|
||||
internalFilter,
|
||||
} from './internalFilter';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
} from './types';
|
||||
|
||||
export const filter = <T extends Object>(
|
||||
ast: LiqeQuery,
|
||||
data: readonly T[],
|
||||
): readonly T[] => {
|
||||
return internalFilter(
|
||||
ast,
|
||||
data,
|
||||
);
|
||||
};
|
||||
295
packages/liqe/src/grammar.ne
Normal file
295
packages/liqe/src/grammar.ne
Normal file
@ -0,0 +1,295 @@
|
||||
@preprocessor typescript
|
||||
|
||||
main -> _ logical_expression _ {% (data) => data[1] %}
|
||||
|
||||
# Whitespace: `_` is optional, `__` is mandatory.
|
||||
_ -> whitespace_character:* {% (data) => data[0].length %}
|
||||
__ -> whitespace_character:+ {% (data) => data[0].length %}
|
||||
|
||||
whitespace_character -> [ \t\n\v\f] {% id %}
|
||||
|
||||
# Numbers
|
||||
decimal -> "-":? [0-9]:+ ("." [0-9]:+):? {%
|
||||
(data) => parseFloat(
|
||||
(data[0] || "") +
|
||||
data[1].join("") +
|
||||
(data[2] ? "."+data[2][1].join("") : "")
|
||||
)
|
||||
%}
|
||||
|
||||
# Double-quoted string
|
||||
dqstring -> "\"" dstrchar:* "\"" {% (data) => data[1].join('') %}
|
||||
sqstring -> "'" sstrchar:* "'" {% (data) => data[1].join('') %}
|
||||
|
||||
dstrchar -> [^\\"\n] {% id %}
|
||||
| "\\" strescape {%
|
||||
(data) => JSON.parse("\""+data.join("")+"\"")
|
||||
%}
|
||||
|
||||
sstrchar -> [^\\'\n] {% id %}
|
||||
| "\\" strescape
|
||||
{% (data) => JSON.parse("\"" + data.join("") + "\"") %}
|
||||
| "\\'"
|
||||
{% () => "'" %}
|
||||
|
||||
strescape -> ["\\/bfnrt] {% id %}
|
||||
| "u" [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] [a-fA-F0-9] {%
|
||||
(data) => data.join('')
|
||||
%}
|
||||
|
||||
logical_expression -> two_op_logical_expression {% id %}
|
||||
|
||||
two_op_logical_expression ->
|
||||
pre_two_op_logical_expression boolean_operator post_one_op_logical_expression {% (data) => ({
|
||||
type: 'LogicalExpression',
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[2].location.end,
|
||||
},
|
||||
operator: data[1],
|
||||
left: data[0],
|
||||
right: data[2]
|
||||
}) %}
|
||||
| pre_two_op_implicit_logical_expression __ post_one_op_implicit_logical_expression {% (data) => ({
|
||||
type: 'LogicalExpression',
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[2].location.end,
|
||||
},
|
||||
operator: {
|
||||
operator: 'AND',
|
||||
type: 'ImplicitBooleanOperator'
|
||||
},
|
||||
left: data[0],
|
||||
right: data[2]
|
||||
}) %}
|
||||
| one_op_logical_expression {% d => d[0] %}
|
||||
|
||||
pre_two_op_implicit_logical_expression ->
|
||||
two_op_logical_expression {% d => d[0] %}
|
||||
| parentheses_open _ two_op_logical_expression _ parentheses_close {% d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, }, type: 'ParenthesizedExpression', expression: d[2]}) %}
|
||||
|
||||
post_one_op_implicit_logical_expression ->
|
||||
one_op_logical_expression {% d => d[0] %}
|
||||
| parentheses_open _ one_op_logical_expression _ parentheses_close {% d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]}) %}
|
||||
|
||||
pre_two_op_logical_expression ->
|
||||
two_op_logical_expression __ {% d => d[0] %}
|
||||
| parentheses_open _ two_op_logical_expression _ parentheses_close {% d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]}) %}
|
||||
|
||||
one_op_logical_expression ->
|
||||
parentheses_open _ parentheses_close {% d => ({location: {start: d[0].location.start, end: d[2].location.start + 1, },type: 'ParenthesizedExpression', expression: {
|
||||
type: 'EmptyExpression',
|
||||
location: {
|
||||
start: d[0].location.start + 1,
|
||||
end: d[0].location.start + 1,
|
||||
},
|
||||
}}) %}
|
||||
| parentheses_open _ two_op_logical_expression _ parentheses_close {% d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]}) %}
|
||||
| "NOT" post_boolean_primary {% (data, start) => {
|
||||
return {
|
||||
type: 'UnaryOperator',
|
||||
operator: 'NOT',
|
||||
operand: data[1],
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
}
|
||||
};
|
||||
} %}
|
||||
| "-" boolean_primary {% (data, start) => {
|
||||
return {
|
||||
type: 'UnaryOperator',
|
||||
operator: '-',
|
||||
operand: data[1],
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
}
|
||||
};
|
||||
} %}
|
||||
| boolean_primary {% d => d[0] %}
|
||||
|
||||
post_one_op_logical_expression ->
|
||||
__ one_op_logical_expression {% d => d[1] %}
|
||||
| parentheses_open _ one_op_logical_expression _ parentheses_close {% d => ({location: {start: d[0].location, end: d[4].location + 1, },type: 'ParenthesizedExpression', expression: d[2]}) %}
|
||||
|
||||
parentheses_open ->
|
||||
"(" {% (data, start) => ({location: {start}}) %}
|
||||
|
||||
parentheses_close ->
|
||||
")" {% (data, start) => ({location: {start}}) %}
|
||||
|
||||
boolean_operator ->
|
||||
"OR" {% (data, start) => ({location: {start, end: start + 2}, operator: 'OR', type: 'BooleanOperator'}) %}
|
||||
| "AND" {% (data, start) => ({location: {start, end: start + 3}, operator: 'AND', type: 'BooleanOperator'}) %}
|
||||
|
||||
boolean_primary ->
|
||||
tag_expression {% id %}
|
||||
|
||||
post_boolean_primary ->
|
||||
__ parentheses_open _ two_op_logical_expression _ parentheses_close {% d => ({location: {start: d[1].location.start, end: d[5].location.start + 1, }, type: 'ParenthesizedExpression', expression: d[3]}) %}
|
||||
| __ boolean_primary {% d => d[1] %}
|
||||
|
||||
tag_expression ->
|
||||
|
||||
field comparison_operator expression {% (data, start) => {
|
||||
const field = {
|
||||
type: 'Field',
|
||||
name: data[0].name,
|
||||
path: data[0].name.split('.').filter(Boolean),
|
||||
quoted: data[0].quoted,
|
||||
quotes: data[0].quotes,
|
||||
location: data[0].location,
|
||||
};
|
||||
|
||||
if (!data[0].quotes) {
|
||||
delete field.quotes;
|
||||
}
|
||||
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
end: data[2].expression.location.end,
|
||||
},
|
||||
field,
|
||||
operator: data[1],
|
||||
...data[2]
|
||||
}
|
||||
} %}
|
||||
| field comparison_operator {% (data, start) => {
|
||||
const field = {
|
||||
type: 'Field',
|
||||
name: data[0].name,
|
||||
path: data[0].name.split('.').filter(Boolean),
|
||||
quoted: data[0].quoted,
|
||||
quotes: data[0].quotes,
|
||||
location: data[0].location,
|
||||
};
|
||||
|
||||
if (!data[0].quotes) {
|
||||
delete field.quotes;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Tag',
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
},
|
||||
field,
|
||||
operator: data[1],
|
||||
expression: {
|
||||
type: 'EmptyExpression',
|
||||
location: {
|
||||
start: data[1].location.end,
|
||||
end: data[1].location.end,
|
||||
},
|
||||
}
|
||||
}
|
||||
} %}
|
||||
| expression {% (data, start) => {
|
||||
return {location: {start, end: data[0].expression.location.end}, field: {type: 'ImplicitField'}, ...data[0]};
|
||||
} %}
|
||||
|
||||
field ->
|
||||
[_a-zA-Z$] [a-zA-Z\d_$.]:* {% (data, start) => ({type: 'LiteralExpression', name: data[0] + data[1].join(''), quoted: false, location: {start, end: start + (data[0] + data[1].join('')).length}}) %}
|
||||
| sqstring {% (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'single', location: {start, end: start + data[0].length + 2}}) %}
|
||||
| dqstring {% (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'double', location: {start, end: start + data[0].length + 2}}) %}
|
||||
|
||||
expression ->
|
||||
decimal {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'LiteralExpression', quoted: false, value: Number(data.join(''))}}) %}
|
||||
| regex {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'RegexExpression', value: data.join('')}}) %}
|
||||
| range {% (data) => data[0] %}
|
||||
| unquoted_value {% (data, start, reject) => {
|
||||
const value = data.join('');
|
||||
|
||||
if (data[0] === 'AND' || data[0] === 'OR' || data[0] === 'NOT') {
|
||||
return reject;
|
||||
}
|
||||
|
||||
let normalizedValue;
|
||||
|
||||
if (value === 'true') {
|
||||
normalizedValue = true;
|
||||
} else if (value === 'false') {
|
||||
normalizedValue = false;
|
||||
} else if (value === 'null') {
|
||||
normalizedValue = null;
|
||||
} else {
|
||||
normalizedValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start,
|
||||
end: start + value.length,
|
||||
},
|
||||
type: 'LiteralExpression',
|
||||
quoted: false,
|
||||
value: normalizedValue
|
||||
},
|
||||
};
|
||||
} %}
|
||||
| sqstring {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'single', value: data.join('')}}) %}
|
||||
| dqstring {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'double', value: data.join('')}}) %}
|
||||
|
||||
range ->
|
||||
range_open decimal " TO " decimal range_close {% (data, start) => {
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
},
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[4].location.start + 1,
|
||||
},
|
||||
type: 'RangeExpression',
|
||||
range: {
|
||||
min: data[1],
|
||||
minInclusive: data[0].inclusive,
|
||||
maxInclusive: data[4].inclusive,
|
||||
max: data[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
} %}
|
||||
|
||||
range_open ->
|
||||
"[" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
| "{" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
|
||||
range_close ->
|
||||
"]" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
| "}" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
|
||||
comparison_operator ->
|
||||
(
|
||||
":"
|
||||
| ":="
|
||||
| ":>"
|
||||
| ":<"
|
||||
| ":>="
|
||||
| ":<="
|
||||
) {% (data, start) => ({location: {start, end: start + data[0][0].length}, type: 'ComparisonOperator', operator: data[0][0]}) %}
|
||||
|
||||
regex ->
|
||||
regex_body regex_flags {% d => d.join('') %}
|
||||
|
||||
regex_body ->
|
||||
"/" regex_body_char:* "/" {% (data) => '/' + data[1].join('') + '/' %}
|
||||
|
||||
regex_body_char ->
|
||||
[^\\] {% id %}
|
||||
| "\\" [^\\] {% d => '\\' + d[1] %}
|
||||
|
||||
regex_flags ->
|
||||
null |
|
||||
[gmiyusd]:+ {% d => d[0].join('') %}
|
||||
|
||||
unquoted_value ->
|
||||
[a-zA-Z_*?@#$\u0080-\uFFFF] [a-zA-Z\.\-_*?@#$\u0080-\uFFFF]:* {% d => d[0] + d[1].join('') %}
|
||||
308
packages/liqe/src/grammar.ts
Normal file
308
packages/liqe/src/grammar.ts
Normal file
@ -0,0 +1,308 @@
|
||||
// Generated automatically by nearley, version 2.20.1
|
||||
// http://github.com/Hardmath123/nearley
|
||||
// Bypasses TS6133. Allow declared but unused functions.
|
||||
// @ts-ignore
|
||||
function id(d: any[]): any { return d[0]; }
|
||||
|
||||
interface NearleyToken {
|
||||
value: any;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface NearleyLexer {
|
||||
reset: (chunk: string, info: any) => void;
|
||||
next: () => NearleyToken | undefined;
|
||||
save: () => any;
|
||||
formatError: (token: never) => string;
|
||||
has: (tokenType: string) => boolean;
|
||||
};
|
||||
|
||||
interface NearleyRule {
|
||||
name: string;
|
||||
symbols: NearleySymbol[];
|
||||
postprocess?: (d: any[], loc: number, reject?: {}) => any;
|
||||
};
|
||||
|
||||
type NearleySymbol = string | { literal: any } | { test: (token: any) => boolean };
|
||||
|
||||
interface Grammar {
|
||||
Lexer: NearleyLexer | undefined;
|
||||
ParserRules: NearleyRule[];
|
||||
ParserStart: string;
|
||||
};
|
||||
|
||||
const grammar: Grammar = {
|
||||
Lexer: undefined,
|
||||
ParserRules: [
|
||||
{"name": "main", "symbols": ["_", "logical_expression", "_"], "postprocess": (data) => data[1]},
|
||||
{"name": "_$ebnf$1", "symbols": []},
|
||||
{"name": "_$ebnf$1", "symbols": ["_$ebnf$1", "whitespace_character"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "_", "symbols": ["_$ebnf$1"], "postprocess": (data) => data[0].length},
|
||||
{"name": "__$ebnf$1", "symbols": ["whitespace_character"]},
|
||||
{"name": "__$ebnf$1", "symbols": ["__$ebnf$1", "whitespace_character"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "__", "symbols": ["__$ebnf$1"], "postprocess": (data) => data[0].length},
|
||||
{"name": "whitespace_character", "symbols": [/[ \t\n\v\f]/], "postprocess": id},
|
||||
{"name": "decimal$ebnf$1", "symbols": [{"literal":"-"}], "postprocess": id},
|
||||
{"name": "decimal$ebnf$1", "symbols": [], "postprocess": () => null},
|
||||
{"name": "decimal$ebnf$2", "symbols": [/[0-9]/]},
|
||||
{"name": "decimal$ebnf$2", "symbols": ["decimal$ebnf$2", /[0-9]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": [/[0-9]/]},
|
||||
{"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": ["decimal$ebnf$3$subexpression$1$ebnf$1", /[0-9]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "decimal$ebnf$3$subexpression$1", "symbols": [{"literal":"."}, "decimal$ebnf$3$subexpression$1$ebnf$1"]},
|
||||
{"name": "decimal$ebnf$3", "symbols": ["decimal$ebnf$3$subexpression$1"], "postprocess": id},
|
||||
{"name": "decimal$ebnf$3", "symbols": [], "postprocess": () => null},
|
||||
{"name": "decimal", "symbols": ["decimal$ebnf$1", "decimal$ebnf$2", "decimal$ebnf$3"], "postprocess":
|
||||
(data) => parseFloat(
|
||||
(data[0] || "") +
|
||||
data[1].join("") +
|
||||
(data[2] ? "."+data[2][1].join("") : "")
|
||||
)
|
||||
},
|
||||
{"name": "dqstring$ebnf$1", "symbols": []},
|
||||
{"name": "dqstring$ebnf$1", "symbols": ["dqstring$ebnf$1", "dstrchar"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "dqstring", "symbols": [{"literal":"\""}, "dqstring$ebnf$1", {"literal":"\""}], "postprocess": (data) => data[1].join('')},
|
||||
{"name": "sqstring$ebnf$1", "symbols": []},
|
||||
{"name": "sqstring$ebnf$1", "symbols": ["sqstring$ebnf$1", "sstrchar"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "sqstring", "symbols": [{"literal":"'"}, "sqstring$ebnf$1", {"literal":"'"}], "postprocess": (data) => data[1].join('')},
|
||||
{"name": "dstrchar", "symbols": [/[^\\"\n]/], "postprocess": id},
|
||||
{"name": "dstrchar", "symbols": [{"literal":"\\"}, "strescape"], "postprocess":
|
||||
(data) => JSON.parse("\""+data.join("")+"\"")
|
||||
},
|
||||
{"name": "sstrchar", "symbols": [/[^\\'\n]/], "postprocess": id},
|
||||
{"name": "sstrchar", "symbols": [{"literal":"\\"}, "strescape"], "postprocess": (data) => JSON.parse("\"" + data.join("") + "\"")},
|
||||
{"name": "sstrchar$string$1", "symbols": [{"literal":"\\"}, {"literal":"'"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "sstrchar", "symbols": ["sstrchar$string$1"], "postprocess": () => "'"},
|
||||
{"name": "strescape", "symbols": [/["\\/bfnrt]/], "postprocess": id},
|
||||
{"name": "strescape", "symbols": [{"literal":"u"}, /[a-fA-F0-9]/, /[a-fA-F0-9]/, /[a-fA-F0-9]/, /[a-fA-F0-9]/], "postprocess":
|
||||
(data) => data.join('')
|
||||
},
|
||||
{"name": "logical_expression", "symbols": ["two_op_logical_expression"], "postprocess": id},
|
||||
{"name": "two_op_logical_expression", "symbols": ["pre_two_op_logical_expression", "boolean_operator", "post_one_op_logical_expression"], "postprocess": (data) => ({
|
||||
type: 'LogicalExpression',
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[2].location.end,
|
||||
},
|
||||
operator: data[1],
|
||||
left: data[0],
|
||||
right: data[2]
|
||||
}) },
|
||||
{"name": "two_op_logical_expression", "symbols": ["pre_two_op_implicit_logical_expression", "__", "post_one_op_implicit_logical_expression"], "postprocess": (data) => ({
|
||||
type: 'LogicalExpression',
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[2].location.end,
|
||||
},
|
||||
operator: {
|
||||
operator: 'AND',
|
||||
type: 'ImplicitBooleanOperator'
|
||||
},
|
||||
left: data[0],
|
||||
right: data[2]
|
||||
}) },
|
||||
{"name": "two_op_logical_expression", "symbols": ["one_op_logical_expression"], "postprocess": d => d[0]},
|
||||
{"name": "pre_two_op_implicit_logical_expression", "symbols": ["two_op_logical_expression"], "postprocess": d => d[0]},
|
||||
{"name": "pre_two_op_implicit_logical_expression", "symbols": ["parentheses_open", "_", "two_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, }, type: 'ParenthesizedExpression', expression: d[2]})},
|
||||
{"name": "post_one_op_implicit_logical_expression", "symbols": ["one_op_logical_expression"], "postprocess": d => d[0]},
|
||||
{"name": "post_one_op_implicit_logical_expression", "symbols": ["parentheses_open", "_", "one_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]})},
|
||||
{"name": "pre_two_op_logical_expression", "symbols": ["two_op_logical_expression", "__"], "postprocess": d => d[0]},
|
||||
{"name": "pre_two_op_logical_expression", "symbols": ["parentheses_open", "_", "two_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]})},
|
||||
{"name": "one_op_logical_expression", "symbols": ["parentheses_open", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location.start, end: d[2].location.start + 1, },type: 'ParenthesizedExpression', expression: {
|
||||
type: 'EmptyExpression',
|
||||
location: {
|
||||
start: d[0].location.start + 1,
|
||||
end: d[0].location.start + 1,
|
||||
},
|
||||
}}) },
|
||||
{"name": "one_op_logical_expression", "symbols": ["parentheses_open", "_", "two_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location.start, end: d[4].location.start + 1, },type: 'ParenthesizedExpression', expression: d[2]})},
|
||||
{"name": "one_op_logical_expression$string$1", "symbols": [{"literal":"N"}, {"literal":"O"}, {"literal":"T"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "one_op_logical_expression", "symbols": ["one_op_logical_expression$string$1", "post_boolean_primary"], "postprocess": (data, start) => {
|
||||
return {
|
||||
type: 'UnaryOperator',
|
||||
operator: 'NOT',
|
||||
operand: data[1],
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
}
|
||||
};
|
||||
} },
|
||||
{"name": "one_op_logical_expression", "symbols": [{"literal":"-"}, "boolean_primary"], "postprocess": (data, start) => {
|
||||
return {
|
||||
type: 'UnaryOperator',
|
||||
operator: '-',
|
||||
operand: data[1],
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
}
|
||||
};
|
||||
} },
|
||||
{"name": "one_op_logical_expression", "symbols": ["boolean_primary"], "postprocess": d => d[0]},
|
||||
{"name": "post_one_op_logical_expression", "symbols": ["__", "one_op_logical_expression"], "postprocess": d => d[1]},
|
||||
{"name": "post_one_op_logical_expression", "symbols": ["parentheses_open", "_", "one_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[0].location, end: d[4].location + 1, },type: 'ParenthesizedExpression', expression: d[2]})},
|
||||
{"name": "parentheses_open", "symbols": [{"literal":"("}], "postprocess": (data, start) => ({location: {start}})},
|
||||
{"name": "parentheses_close", "symbols": [{"literal":")"}], "postprocess": (data, start) => ({location: {start}})},
|
||||
{"name": "boolean_operator$string$1", "symbols": [{"literal":"O"}, {"literal":"R"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "boolean_operator", "symbols": ["boolean_operator$string$1"], "postprocess": (data, start) => ({location: {start, end: start + 2}, operator: 'OR', type: 'BooleanOperator'})},
|
||||
{"name": "boolean_operator$string$2", "symbols": [{"literal":"A"}, {"literal":"N"}, {"literal":"D"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "boolean_operator", "symbols": ["boolean_operator$string$2"], "postprocess": (data, start) => ({location: {start, end: start + 3}, operator: 'AND', type: 'BooleanOperator'})},
|
||||
{"name": "boolean_primary", "symbols": ["tag_expression"], "postprocess": id},
|
||||
{"name": "post_boolean_primary", "symbols": ["__", "parentheses_open", "_", "two_op_logical_expression", "_", "parentheses_close"], "postprocess": d => ({location: {start: d[1].location.start, end: d[5].location.start + 1, }, type: 'ParenthesizedExpression', expression: d[3]})},
|
||||
{"name": "post_boolean_primary", "symbols": ["__", "boolean_primary"], "postprocess": d => d[1]},
|
||||
{"name": "tag_expression", "symbols": ["field", "comparison_operator", "expression"], "postprocess": (data, start) => {
|
||||
const field = {
|
||||
type: 'Field',
|
||||
name: data[0].name,
|
||||
path: data[0].name.split('.').filter(Boolean),
|
||||
quoted: data[0].quoted,
|
||||
quotes: data[0].quotes,
|
||||
location: data[0].location,
|
||||
};
|
||||
|
||||
if (!data[0].quotes) {
|
||||
delete field.quotes;
|
||||
}
|
||||
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
end: data[2].expression.location.end,
|
||||
},
|
||||
field,
|
||||
operator: data[1],
|
||||
...data[2]
|
||||
}
|
||||
} },
|
||||
{"name": "tag_expression", "symbols": ["field", "comparison_operator"], "postprocess": (data, start) => {
|
||||
const field = {
|
||||
type: 'Field',
|
||||
name: data[0].name,
|
||||
path: data[0].name.split('.').filter(Boolean),
|
||||
quoted: data[0].quoted,
|
||||
quotes: data[0].quotes,
|
||||
location: data[0].location,
|
||||
};
|
||||
|
||||
if (!data[0].quotes) {
|
||||
delete field.quotes;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Tag',
|
||||
location: {
|
||||
start,
|
||||
end: data[1].location.end,
|
||||
},
|
||||
field,
|
||||
operator: data[1],
|
||||
expression: {
|
||||
type: 'EmptyExpression',
|
||||
location: {
|
||||
start: data[1].location.end,
|
||||
end: data[1].location.end,
|
||||
},
|
||||
}
|
||||
}
|
||||
} },
|
||||
{"name": "tag_expression", "symbols": ["expression"], "postprocess": (data, start) => {
|
||||
return {location: {start, end: data[0].expression.location.end}, field: {type: 'ImplicitField'}, ...data[0]};
|
||||
} },
|
||||
{"name": "field$ebnf$1", "symbols": []},
|
||||
{"name": "field$ebnf$1", "symbols": ["field$ebnf$1", /[a-zA-Z\d_$.]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "field", "symbols": [/[_a-zA-Z$]/, "field$ebnf$1"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0] + data[1].join(''), quoted: false, location: {start, end: start + (data[0] + data[1].join('')).length}})},
|
||||
{"name": "field", "symbols": ["sqstring"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'single', location: {start, end: start + data[0].length + 2}})},
|
||||
{"name": "field", "symbols": ["dqstring"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'double', location: {start, end: start + data[0].length + 2}})},
|
||||
{"name": "expression", "symbols": ["decimal"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'LiteralExpression', quoted: false, value: Number(data.join(''))}})},
|
||||
{"name": "expression", "symbols": ["regex"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'RegexExpression', value: data.join('')}})},
|
||||
{"name": "expression", "symbols": ["range"], "postprocess": (data) => data[0]},
|
||||
{"name": "expression", "symbols": ["unquoted_value"], "postprocess": (data, start, reject) => {
|
||||
const value = data.join('');
|
||||
|
||||
if (data[0] === 'AND' || data[0] === 'OR' || data[0] === 'NOT') {
|
||||
return reject;
|
||||
}
|
||||
|
||||
let normalizedValue;
|
||||
|
||||
if (value === 'true') {
|
||||
normalizedValue = true;
|
||||
} else if (value === 'false') {
|
||||
normalizedValue = false;
|
||||
} else if (value === 'null') {
|
||||
normalizedValue = null;
|
||||
} else {
|
||||
normalizedValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start,
|
||||
end: start + value.length,
|
||||
},
|
||||
type: 'LiteralExpression',
|
||||
quoted: false,
|
||||
value: normalizedValue
|
||||
},
|
||||
};
|
||||
} },
|
||||
{"name": "expression", "symbols": ["sqstring"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'single', value: data.join('')}})},
|
||||
{"name": "expression", "symbols": ["dqstring"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'double', value: data.join('')}})},
|
||||
{"name": "range$string$1", "symbols": [{"literal":" "}, {"literal":"T"}, {"literal":"O"}, {"literal":" "}], "postprocess": (d) => d.join('')},
|
||||
{"name": "range", "symbols": ["range_open", "decimal", "range$string$1", "decimal", "range_close"], "postprocess": (data, start) => {
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
},
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[4].location.start + 1,
|
||||
},
|
||||
type: 'RangeExpression',
|
||||
range: {
|
||||
min: data[1],
|
||||
minInclusive: data[0].inclusive,
|
||||
maxInclusive: data[4].inclusive,
|
||||
max: data[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
} },
|
||||
{"name": "range_open", "symbols": [{"literal":"["}], "postprocess": (data, start) => ({location: {start}, inclusive: true})},
|
||||
{"name": "range_open", "symbols": [{"literal":"{"}], "postprocess": (data, start) => ({location: {start}, inclusive: false})},
|
||||
{"name": "range_close", "symbols": [{"literal":"]"}], "postprocess": (data, start) => ({location: {start}, inclusive: true})},
|
||||
{"name": "range_close", "symbols": [{"literal":"}"}], "postprocess": (data, start) => ({location: {start}, inclusive: false})},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": [{"literal":":"}]},
|
||||
{"name": "comparison_operator$subexpression$1$string$1", "symbols": [{"literal":":"}, {"literal":"="}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$1"]},
|
||||
{"name": "comparison_operator$subexpression$1$string$2", "symbols": [{"literal":":"}, {"literal":">"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$2"]},
|
||||
{"name": "comparison_operator$subexpression$1$string$3", "symbols": [{"literal":":"}, {"literal":"<"}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$3"]},
|
||||
{"name": "comparison_operator$subexpression$1$string$4", "symbols": [{"literal":":"}, {"literal":">"}, {"literal":"="}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$4"]},
|
||||
{"name": "comparison_operator$subexpression$1$string$5", "symbols": [{"literal":":"}, {"literal":"<"}, {"literal":"="}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$5"]},
|
||||
{"name": "comparison_operator", "symbols": ["comparison_operator$subexpression$1"], "postprocess": (data, start) => ({location: {start, end: start + data[0][0].length}, type: 'ComparisonOperator', operator: data[0][0]})},
|
||||
{"name": "regex", "symbols": ["regex_body", "regex_flags"], "postprocess": d => d.join('')},
|
||||
{"name": "regex_body$ebnf$1", "symbols": []},
|
||||
{"name": "regex_body$ebnf$1", "symbols": ["regex_body$ebnf$1", "regex_body_char"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "regex_body", "symbols": [{"literal":"/"}, "regex_body$ebnf$1", {"literal":"/"}], "postprocess": (data) => '/' + data[1].join('') + '/'},
|
||||
{"name": "regex_body_char", "symbols": [/[^\\]/], "postprocess": id},
|
||||
{"name": "regex_body_char", "symbols": [{"literal":"\\"}, /[^\\]/], "postprocess": d => '\\' + d[1]},
|
||||
{"name": "regex_flags", "symbols": []},
|
||||
{"name": "regex_flags$ebnf$1", "symbols": [/[gmiyusd]/]},
|
||||
{"name": "regex_flags$ebnf$1", "symbols": ["regex_flags$ebnf$1", /[gmiyusd]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "regex_flags", "symbols": ["regex_flags$ebnf$1"], "postprocess": d => d[0].join('')},
|
||||
{"name": "unquoted_value$ebnf$1", "symbols": []},
|
||||
{"name": "unquoted_value$ebnf$1", "symbols": ["unquoted_value$ebnf$1", /[a-zA-Z\.\-_*?@#$\u0080-\uFFFF]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "unquoted_value", "symbols": [/[a-zA-Z_*?@#$\u0080-\uFFFF]/, "unquoted_value$ebnf$1"], "postprocess": d => d[0] + d[1].join('')}
|
||||
],
|
||||
ParserStart: "main",
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
67
packages/liqe/src/highlight.ts
Normal file
67
packages/liqe/src/highlight.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
escapeRegexString,
|
||||
} from './escapeRegexString';
|
||||
import {
|
||||
internalFilter,
|
||||
} from './internalFilter';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
Highlight,
|
||||
InternalHighlight,
|
||||
} from './types';
|
||||
|
||||
type AggregatedHighlight = {
|
||||
keywords: string[],
|
||||
path: string,
|
||||
};
|
||||
|
||||
export const highlight = <T extends Object>(
|
||||
ast: LiqeQuery,
|
||||
data: T,
|
||||
): Highlight[] => {
|
||||
const highlights: InternalHighlight[] = [];
|
||||
|
||||
internalFilter(
|
||||
ast,
|
||||
[data],
|
||||
false,
|
||||
[],
|
||||
highlights,
|
||||
);
|
||||
|
||||
const aggregatedHighlights: AggregatedHighlight[] = [];
|
||||
|
||||
for (const highlightNode of highlights) {
|
||||
let aggregatedHighlight = aggregatedHighlights.find((maybeTarget) => {
|
||||
return maybeTarget.path === highlightNode.path;
|
||||
});
|
||||
|
||||
if (!aggregatedHighlight) {
|
||||
aggregatedHighlight = {
|
||||
keywords: [],
|
||||
path: highlightNode.path,
|
||||
};
|
||||
|
||||
aggregatedHighlights.push(aggregatedHighlight);
|
||||
}
|
||||
|
||||
if (highlightNode.keyword) {
|
||||
aggregatedHighlight.keywords.push(highlightNode.keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return aggregatedHighlights.map((aggregatedHighlight) => {
|
||||
if (aggregatedHighlight.keywords.length > 0) {
|
||||
return {
|
||||
path: aggregatedHighlight.path,
|
||||
query: new RegExp('(' + aggregatedHighlight.keywords.map((keyword) => {
|
||||
return escapeRegexString(keyword.trim());
|
||||
}).join('|') + ')'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: aggregatedHighlight.path,
|
||||
};
|
||||
});
|
||||
};
|
||||
42
packages/liqe/src/hydrateAst.ts
Normal file
42
packages/liqe/src/hydrateAst.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/no-implied-eval */
|
||||
/* eslint-disable no-new-func */
|
||||
|
||||
import {
|
||||
createGetValueFunctionBody,
|
||||
} from './createGetValueFunctionBody';
|
||||
import {
|
||||
isSafePath,
|
||||
} from './isSafePath';
|
||||
import type {
|
||||
ParserAst,
|
||||
LiqeQuery,
|
||||
} from './types';
|
||||
|
||||
export const hydrateAst = (subject: ParserAst): LiqeQuery => {
|
||||
const newSubject: LiqeQuery = {
|
||||
...subject,
|
||||
};
|
||||
|
||||
if (
|
||||
subject.type === 'Tag' &&
|
||||
subject.field.type === 'Field' &&
|
||||
'field' in subject &&
|
||||
isSafePath(subject.field.name)
|
||||
) {
|
||||
newSubject.getValue = new Function('subject', createGetValueFunctionBody(subject.field.name)) as (subject: unknown) => unknown;
|
||||
}
|
||||
|
||||
if ('left' in subject) {
|
||||
newSubject.left = hydrateAst(subject.left);
|
||||
}
|
||||
|
||||
if ('right' in subject) {
|
||||
newSubject.right = hydrateAst(subject.right);
|
||||
}
|
||||
|
||||
if ('operand' in subject) {
|
||||
newSubject.operand = hydrateAst(subject.operand);
|
||||
}
|
||||
|
||||
return newSubject;
|
||||
};
|
||||
316
packages/liqe/src/internalFilter.ts
Normal file
316
packages/liqe/src/internalFilter.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import {
|
||||
createStringTest,
|
||||
} from './createStringTest';
|
||||
import {
|
||||
testComparisonRange,
|
||||
} from './testComparisonRange';
|
||||
import {
|
||||
testRange,
|
||||
} from './testRange';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
InternalHighlight,
|
||||
InternalTest,
|
||||
} from './types';
|
||||
|
||||
const createValueTest = (ast: LiqeQuery): InternalTest => {
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
const {
|
||||
expression,
|
||||
} = ast;
|
||||
|
||||
if (expression.type === 'RangeExpression') {
|
||||
return (value) => {
|
||||
return testRange(value, expression.range);
|
||||
};
|
||||
}
|
||||
|
||||
if (expression.type === 'EmptyExpression') {
|
||||
return () => {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const expressionValue = expression.value;
|
||||
|
||||
if (ast.operator && ast.operator.operator !== ':') {
|
||||
const operator = ast.operator;
|
||||
|
||||
if (typeof expressionValue !== 'number') {
|
||||
throw new TypeError('Expected a number.');
|
||||
}
|
||||
|
||||
return (value) => {
|
||||
if (typeof value !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return testComparisonRange(expressionValue, value, operator.operator);
|
||||
};
|
||||
} else if (typeof expressionValue === 'boolean') {
|
||||
return (value) => {
|
||||
return value === expressionValue;
|
||||
};
|
||||
} else if (expressionValue === null) {
|
||||
return (value) => {
|
||||
return value === null;
|
||||
};
|
||||
} else {
|
||||
const testString = createStringTest({}, ast);
|
||||
|
||||
return (value) => {
|
||||
return testString(String(value));
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const testValue = (
|
||||
ast: LiqeQuery,
|
||||
value: unknown,
|
||||
resultFast: boolean,
|
||||
path: readonly string[],
|
||||
highlights: InternalHighlight[],
|
||||
) => {
|
||||
if (Array.isArray(value)) {
|
||||
let foundMatch = false;
|
||||
let index = 0;
|
||||
|
||||
for (const item of value) {
|
||||
if (testValue(ast, item, resultFast, [...path, String(index++)], highlights)) {
|
||||
if (resultFast) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foundMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return foundMatch;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
let foundMatch = false;
|
||||
|
||||
for (const key in value) {
|
||||
if (testValue(ast, value[key], resultFast, [...path, key], highlights)) {
|
||||
if (resultFast) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foundMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return foundMatch;
|
||||
}
|
||||
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
if (!ast.test) {
|
||||
throw new Error('Expected test to be defined.');
|
||||
}
|
||||
|
||||
const result = ast.test(
|
||||
value,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
highlights.push({
|
||||
...typeof result === 'string' && {keyword: result},
|
||||
path: path.join('.'),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
result,
|
||||
);
|
||||
};
|
||||
|
||||
const testField = <T extends Object>(
|
||||
row: T,
|
||||
ast: LiqeQuery,
|
||||
resultFast: boolean,
|
||||
path: readonly string[],
|
||||
highlights: InternalHighlight[],
|
||||
): boolean => {
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
if (!ast.test) {
|
||||
ast.test = createValueTest(ast);
|
||||
}
|
||||
|
||||
if (ast.field.type === 'ImplicitField') {
|
||||
let foundMatch = false;
|
||||
|
||||
for (const fieldName in row) {
|
||||
if (testValue(
|
||||
{
|
||||
...ast,
|
||||
field: {
|
||||
location: {
|
||||
end: -1,
|
||||
start: -1,
|
||||
},
|
||||
name: fieldName,
|
||||
quoted: true,
|
||||
quotes: 'double',
|
||||
type: 'Field',
|
||||
},
|
||||
},
|
||||
row[fieldName],
|
||||
resultFast,
|
||||
[
|
||||
...path,
|
||||
fieldName,
|
||||
],
|
||||
highlights,
|
||||
)) {
|
||||
if (resultFast) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foundMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return foundMatch;
|
||||
}
|
||||
|
||||
if (ast.field.name in row) {
|
||||
return testValue(
|
||||
ast,
|
||||
row[ast.field.name],
|
||||
resultFast,
|
||||
path,
|
||||
highlights,
|
||||
);
|
||||
} else if (ast.getValue && ast.field.path) {
|
||||
return testValue(
|
||||
ast,
|
||||
ast.getValue(row),
|
||||
resultFast,
|
||||
ast.field.path,
|
||||
highlights,
|
||||
);
|
||||
} else if (ast.field.path) {
|
||||
let value = row;
|
||||
|
||||
for (const key of ast.field.path) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
} else if (key in value) {
|
||||
value = value[key];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return testValue(
|
||||
ast,
|
||||
value,
|
||||
resultFast,
|
||||
ast.field.path,
|
||||
highlights,
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const internalFilter = <T extends Object>(
|
||||
ast: LiqeQuery,
|
||||
rows: readonly T[],
|
||||
resultFast: boolean = true,
|
||||
path: readonly string[] = [],
|
||||
highlights: InternalHighlight[] = [],
|
||||
): readonly T[] => {
|
||||
if (ast.type === 'Tag') {
|
||||
return rows.filter((row) => {
|
||||
return testField(
|
||||
row,
|
||||
ast,
|
||||
resultFast,
|
||||
ast.field.type === 'ImplicitField' ? path : [...path, ast.field.name],
|
||||
highlights,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (ast.type === 'UnaryOperator') {
|
||||
const removeRows = internalFilter(
|
||||
ast.operand,
|
||||
rows,
|
||||
resultFast,
|
||||
path,
|
||||
[],
|
||||
);
|
||||
|
||||
return rows.filter((row) => {
|
||||
return !removeRows.includes(row);
|
||||
});
|
||||
}
|
||||
|
||||
if (ast.type === 'ParenthesizedExpression') {
|
||||
return internalFilter(
|
||||
ast.expression,
|
||||
rows,
|
||||
resultFast,
|
||||
path,
|
||||
highlights,
|
||||
);
|
||||
}
|
||||
|
||||
if (!ast.left) {
|
||||
throw new Error('Expected left to be defined.');
|
||||
}
|
||||
|
||||
const leftRows = internalFilter(
|
||||
ast.left,
|
||||
rows,
|
||||
resultFast,
|
||||
path,
|
||||
highlights,
|
||||
);
|
||||
|
||||
if (!ast.right) {
|
||||
throw new Error('Expected right to be defined.');
|
||||
}
|
||||
|
||||
if (ast.type !== 'LogicalExpression') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
if (ast.operator.operator === 'OR') {
|
||||
const rightRows = internalFilter(
|
||||
ast.right,
|
||||
rows,
|
||||
resultFast,
|
||||
path,
|
||||
highlights,
|
||||
);
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
...leftRows,
|
||||
...rightRows,
|
||||
]),
|
||||
);
|
||||
} else if (ast.operator.operator === 'AND') {
|
||||
return internalFilter(
|
||||
ast.right,
|
||||
leftRows,
|
||||
resultFast,
|
||||
path,
|
||||
highlights,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected state.');
|
||||
};
|
||||
5
packages/liqe/src/isSafePath.ts
Normal file
5
packages/liqe/src/isSafePath.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const SAFE_PATH_RULE = /^(\.(?:[_a-zA-Z][a-zA-Z\d_]*|\0|[1-9]\d*))+$/u;
|
||||
|
||||
export const isSafePath = (path: string): boolean => {
|
||||
return SAFE_PATH_RULE.test(path);
|
||||
};
|
||||
3
packages/liqe/src/isSafeUnquotedExpression.ts
Normal file
3
packages/liqe/src/isSafeUnquotedExpression.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const isSafeUnquotedExpression = (expression: string): boolean => {
|
||||
return /^[#$*@A-Z_a-z][#$*.@A-Z_a-z-]*$/.test(expression);
|
||||
};
|
||||
65
packages/liqe/src/parse.ts
Normal file
65
packages/liqe/src/parse.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import nearley from 'nearley';
|
||||
import {
|
||||
SyntaxError,
|
||||
} from './errors';
|
||||
import grammar from './grammar';
|
||||
import {
|
||||
hydrateAst,
|
||||
} from './hydrateAst';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
ParserAst,
|
||||
} from './types';
|
||||
|
||||
const rules = nearley.Grammar.fromCompiled(grammar);
|
||||
|
||||
const MESSAGE_RULE = /Syntax error at line (?<line>\d+) col (?<column>\d+)/;
|
||||
|
||||
export const parse = (query: string): LiqeQuery => {
|
||||
if (query.trim() === '') {
|
||||
return {
|
||||
location: {
|
||||
end: 0,
|
||||
start: 0,
|
||||
},
|
||||
type: 'EmptyExpression',
|
||||
};
|
||||
}
|
||||
|
||||
const parser = new nearley.Parser(rules);
|
||||
|
||||
let results;
|
||||
|
||||
try {
|
||||
results = parser.feed(query).results as ParserAst;
|
||||
} catch (error: any) {
|
||||
if (typeof error?.message === 'string' && typeof error?.offset === 'number') {
|
||||
const match = error.message.match(MESSAGE_RULE);
|
||||
|
||||
if (!match) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new SyntaxError(
|
||||
`Syntax error at line ${match.groups.line} column ${match.groups.column}`,
|
||||
error.offset,
|
||||
Number(match.groups.line),
|
||||
Number(match.groups.column),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error('Found no parsings.');
|
||||
}
|
||||
|
||||
if (results.length > 1) {
|
||||
throw new Error('Ambiguous results.');
|
||||
}
|
||||
|
||||
const hydratedAst = hydrateAst(results[0]);
|
||||
|
||||
return hydratedAst;
|
||||
};
|
||||
16
packages/liqe/src/parseRegex.ts
Normal file
16
packages/liqe/src/parseRegex.ts
Normal file
@ -0,0 +1,16 @@
|
||||
const RegExpRule = /(\/?)(.+)\1([a-z]*)/;
|
||||
const FlagRule = /^(?!.*?(.).*?\1)[AJUXgimsux]+$/;
|
||||
|
||||
export const parseRegex = (subject: string): RegExp => {
|
||||
const match = RegExpRule.exec(subject);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid RegExp.');
|
||||
}
|
||||
|
||||
if (match[3] && !FlagRule.test(match[3])) {
|
||||
return new RegExp(subject);
|
||||
}
|
||||
|
||||
return new RegExp(match[2], match[3]);
|
||||
};
|
||||
114
packages/liqe/src/serialize.ts
Normal file
114
packages/liqe/src/serialize.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type {
|
||||
ExpressionToken,
|
||||
LiqeQuery,
|
||||
} from './types';
|
||||
|
||||
const quote = (value: string, quotes: 'double' | 'single') => {
|
||||
if (quotes === 'double') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
if (quotes === 'single') {
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const serializeExpression = (expression: ExpressionToken) => {
|
||||
if (expression.type === 'LiteralExpression') {
|
||||
if (expression.quoted && typeof expression.value === 'string') {
|
||||
return quote(expression.value, expression.quotes);
|
||||
}
|
||||
|
||||
return String(expression.value);
|
||||
}
|
||||
|
||||
if (expression.type === 'RegexExpression') {
|
||||
return String(expression.value);
|
||||
}
|
||||
|
||||
if (expression.type === 'RangeExpression') {
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
minInclusive,
|
||||
maxInclusive,
|
||||
} = expression.range;
|
||||
|
||||
return `${minInclusive ? '[' : '{'}${min} TO ${max}${maxInclusive ? ']' : '}'}`;
|
||||
}
|
||||
|
||||
if (expression.type === 'EmptyExpression') {
|
||||
return '';
|
||||
}
|
||||
|
||||
throw new Error('Unexpected AST type.');
|
||||
};
|
||||
|
||||
const serializeTag = (ast: LiqeQuery) => {
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.');
|
||||
}
|
||||
|
||||
const {
|
||||
field,
|
||||
expression,
|
||||
operator,
|
||||
} = ast;
|
||||
|
||||
if (field.type === 'ImplicitField') {
|
||||
return serializeExpression(expression);
|
||||
}
|
||||
|
||||
const left = field.quoted ? quote(field.name, field.quotes) : field.name;
|
||||
|
||||
const patEnd = ' '.repeat(expression.location.start - operator.location.end);
|
||||
|
||||
return left + operator.operator + patEnd + serializeExpression(expression);
|
||||
};
|
||||
|
||||
export const serialize = (ast: LiqeQuery): string => {
|
||||
if (ast.type === 'ParenthesizedExpression') {
|
||||
if (!('location' in ast.expression)) {
|
||||
throw new Error('Expected location in expression.');
|
||||
}
|
||||
|
||||
if (!ast.location.end) {
|
||||
throw new Error('Expected location end.');
|
||||
}
|
||||
|
||||
const patStart = ' '.repeat(ast.expression.location.start - (ast.location.start + 1));
|
||||
const patEnd = ' '.repeat(ast.location.end - ast.expression.location.end - 1);
|
||||
|
||||
return `(${patStart}${serialize(ast.expression)}${patEnd})`;
|
||||
}
|
||||
|
||||
if (ast.type === 'Tag') {
|
||||
return serializeTag(ast);
|
||||
}
|
||||
|
||||
if (ast.type === 'LogicalExpression') {
|
||||
let operator = '';
|
||||
|
||||
if (ast.operator.type === 'BooleanOperator') {
|
||||
operator += ' '.repeat(ast.operator.location.start - ast.left.location.end);
|
||||
operator += ast.operator.operator;
|
||||
operator += ' '.repeat(ast.right.location.start - ast.operator.location.end);
|
||||
} else {
|
||||
operator = ' '.repeat(ast.right.location.start - ast.left.location.end);
|
||||
}
|
||||
|
||||
return `${serialize(ast.left)}${operator}${serialize(ast.right)}`;
|
||||
}
|
||||
|
||||
if (ast.type === 'UnaryOperator') {
|
||||
return (ast.operator === 'NOT' ? 'NOT ' : ast.operator) + serialize(ast.operand);
|
||||
}
|
||||
|
||||
if (ast.type === 'EmptyExpression') {
|
||||
return '';
|
||||
}
|
||||
|
||||
throw new Error('Unexpected AST type.');
|
||||
};
|
||||
10
packages/liqe/src/test.ts
Normal file
10
packages/liqe/src/test.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import {
|
||||
filter,
|
||||
} from './filter';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
} from './types';
|
||||
|
||||
export const test = <T extends Object>(ast: LiqeQuery, subject: T) => {
|
||||
return filter(ast, [subject]).length === 1;
|
||||
};
|
||||
14
packages/liqe/src/testComparisonRange.ts
Normal file
14
packages/liqe/src/testComparisonRange.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
} from './types';
|
||||
|
||||
export const testComparisonRange = (query: number, value: number, operator: ComparisonOperator): boolean => {
|
||||
switch (operator) {
|
||||
case ':=': return value === query;
|
||||
case ':>': return value > query;
|
||||
case ':<': return value < query;
|
||||
case ':>=': return value >= query;
|
||||
case ':<=': return value <= query;
|
||||
default: throw new Error(`Unimplemented comparison operator: ${operator}`);
|
||||
}
|
||||
};
|
||||
29
packages/liqe/src/testRange.ts
Normal file
29
packages/liqe/src/testRange.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type {
|
||||
Range,
|
||||
} from './types';
|
||||
|
||||
export const testRange = (value: unknown, range: Range): boolean => {
|
||||
if (typeof value === 'number') {
|
||||
if (value < range.min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === range.min && !range.minInclusive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value > range.max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === range.max && !range.maxInclusive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @todo handle non-numeric ranges -- https://github.com/gajus/liqe/issues/3
|
||||
|
||||
return false;
|
||||
};
|
||||
134
packages/liqe/src/types.ts
Normal file
134
packages/liqe/src/types.ts
Normal file
@ -0,0 +1,134 @@
|
||||
export type Range = {
|
||||
max: number,
|
||||
maxInclusive: boolean,
|
||||
min: number,
|
||||
minInclusive: boolean,
|
||||
};
|
||||
|
||||
export type ComparisonOperator = ':' | ':<' | ':<=' | ':=' | ':>' | ':>=';
|
||||
|
||||
export type ComparisonOperatorToken = {
|
||||
location: TokenLocation,
|
||||
operator: ComparisonOperator,
|
||||
type: 'ComparisonOperator',
|
||||
};
|
||||
|
||||
export type ImplicitFieldToken = {
|
||||
type: 'ImplicitField',
|
||||
};
|
||||
|
||||
export type FieldToken = {
|
||||
location: TokenLocation,
|
||||
name: string,
|
||||
path?: readonly string[],
|
||||
type: 'Field',
|
||||
} & (
|
||||
{
|
||||
quoted: false,
|
||||
} | {
|
||||
quoted: true,
|
||||
quotes: 'double' | 'single',
|
||||
}
|
||||
);
|
||||
|
||||
export type RegexExpressionToken = {
|
||||
location: TokenLocation,
|
||||
type: 'RegexExpression',
|
||||
value: string,
|
||||
};
|
||||
|
||||
export type RangeExpressionToken = {
|
||||
location: TokenLocation,
|
||||
range: Range,
|
||||
type: 'RangeExpression',
|
||||
};
|
||||
|
||||
export type LiteralExpressionToken = {
|
||||
location: TokenLocation,
|
||||
type: 'LiteralExpression',
|
||||
} & (
|
||||
{
|
||||
quoted: false,
|
||||
value: boolean | string | null,
|
||||
} | {
|
||||
quoted: true,
|
||||
quotes: 'double' | 'single',
|
||||
value: string,
|
||||
}
|
||||
);
|
||||
|
||||
export type EmptyExpression = {
|
||||
location: TokenLocation,
|
||||
type: 'EmptyExpression',
|
||||
};
|
||||
|
||||
export type ExpressionToken = EmptyExpression | LiteralExpressionToken | RangeExpressionToken | RegexExpressionToken;
|
||||
|
||||
export type BooleanOperatorToken = {
|
||||
location: TokenLocation,
|
||||
operator: 'AND' | 'OR',
|
||||
type: 'BooleanOperator',
|
||||
};
|
||||
|
||||
// Implicit boolean operators do not have a location, e.g., "foo bar".
|
||||
// In this example, the implicit AND operator is the space between "foo" and "bar".
|
||||
export type ImplicitBooleanOperatorToken = {
|
||||
operator: 'AND',
|
||||
type: 'ImplicitBooleanOperator',
|
||||
};
|
||||
|
||||
export type TokenLocation = {
|
||||
end: number,
|
||||
start: number,
|
||||
};
|
||||
|
||||
export type TagToken = {
|
||||
expression: ExpressionToken,
|
||||
field: FieldToken | ImplicitFieldToken,
|
||||
location: TokenLocation,
|
||||
operator: ComparisonOperatorToken,
|
||||
test?: InternalTest,
|
||||
type: 'Tag',
|
||||
};
|
||||
|
||||
export type LogicalExpressionToken = {
|
||||
left: ParserAst,
|
||||
location: TokenLocation,
|
||||
operator: BooleanOperatorToken | ImplicitBooleanOperatorToken,
|
||||
right: ParserAst,
|
||||
type: 'LogicalExpression',
|
||||
};
|
||||
|
||||
export type UnaryOperatorToken = {
|
||||
location: TokenLocation,
|
||||
operand: ParserAst,
|
||||
operator: '-' | 'NOT',
|
||||
type: 'UnaryOperator',
|
||||
};
|
||||
|
||||
export type ParenthesizedExpressionToken = {
|
||||
expression: ParserAst,
|
||||
location: TokenLocation,
|
||||
type: 'ParenthesizedExpression',
|
||||
};
|
||||
|
||||
export type ParserAst = EmptyExpression | LogicalExpressionToken | ParenthesizedExpressionToken | TagToken | UnaryOperatorToken;
|
||||
|
||||
export type LiqeQuery = ParserAst & {
|
||||
getValue?: (subject: unknown) => unknown,
|
||||
left?: LiqeQuery,
|
||||
operand?: LiqeQuery,
|
||||
right?: LiqeQuery,
|
||||
};
|
||||
|
||||
export type InternalHighlight = {
|
||||
keyword?: string,
|
||||
path: string,
|
||||
};
|
||||
|
||||
export type Highlight = {
|
||||
path: string,
|
||||
query?: RegExp,
|
||||
};
|
||||
|
||||
export type InternalTest = (value: unknown) => boolean | string;
|
||||
125
packages/liqe/test/benchmark.ts
Normal file
125
packages/liqe/test/benchmark.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
add,
|
||||
complete,
|
||||
cycle,
|
||||
suite,
|
||||
} from 'benny';
|
||||
import faker from 'faker';
|
||||
import {
|
||||
parse,
|
||||
filter,
|
||||
} from '../src/Liqe';
|
||||
|
||||
const randomInRange = (min: number, max: number) => {
|
||||
return Math.floor(
|
||||
Math.random() * (Math.ceil(max) - Math.floor(min) + 1) + min,
|
||||
);
|
||||
};
|
||||
|
||||
type Person = {
|
||||
email: string,
|
||||
foo: {
|
||||
bar: {
|
||||
baz: string,
|
||||
},
|
||||
},
|
||||
height: number,
|
||||
name: string,
|
||||
};
|
||||
|
||||
const persons: Person[] = [];
|
||||
|
||||
let size = 10_000;
|
||||
|
||||
while (size--) {
|
||||
persons.push({
|
||||
email: faker.internet.email(),
|
||||
foo: {
|
||||
bar: {
|
||||
baz: faker.name.findName(),
|
||||
},
|
||||
},
|
||||
height: randomInRange(160, 220),
|
||||
name: faker.name.findName(),
|
||||
});
|
||||
}
|
||||
|
||||
void suite(
|
||||
'liqe',
|
||||
|
||||
add('filters list by the "name" field using simple strict equality check', () => {
|
||||
const query = parse('name:"Gajus"');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "name" field using regex check', () => {
|
||||
const query = parse('name:/Gajus/ui');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "name" field using loose inclusion check', () => {
|
||||
const query = parse('name:Gajus');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "name" field using star (*) wildcard check', () => {
|
||||
const query = parse('name:Ga*');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "name" field using question mark (?) wildcard check', () => {
|
||||
const query = parse('name:Gaju?');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by any field using loose inclusion check', () => {
|
||||
const query = parse('Gajus');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "height" field using strict equality check', () => {
|
||||
const query = parse('height:180');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "height" field using range check', () => {
|
||||
const query = parse('height:[160 TO 180]');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
add('filters list by the "foo.bar.baz" field using simple strict equality check', () => {
|
||||
const query = parse('foo.bar.baz:"Gajus"');
|
||||
|
||||
return () => {
|
||||
filter(query, persons);
|
||||
};
|
||||
}),
|
||||
|
||||
cycle(),
|
||||
complete(),
|
||||
);
|
||||
|
||||
5
packages/liqe/test/liqe/.eslintrc
Normal file
5
packages/liqe/test/liqe/.eslintrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"id-length": 0
|
||||
}
|
||||
}
|
||||
16
packages/liqe/test/liqe/convertWildcardToRegex.ts
Normal file
16
packages/liqe/test/liqe/convertWildcardToRegex.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
convertWildcardToRegex,
|
||||
} from '../../src/convertWildcardToRegex';
|
||||
|
||||
const testRule = test.macro((t, regex: RegExp) => {
|
||||
t.deepEqual(convertWildcardToRegex(t.title), regex);
|
||||
});
|
||||
|
||||
test('*', testRule, /(.+?)/);
|
||||
test('?', testRule, /(.)/);
|
||||
test('foo*bar', testRule, /foo(.+?)bar/);
|
||||
test('foo***bar', testRule, /foo(.+?)bar/);
|
||||
test('foo*bar*', testRule, /foo(.+?)bar(.+?)/);
|
||||
test('foo?bar', testRule, /foo(.)bar/);
|
||||
test('foo???bar', testRule, /foo(.)(.)(.)bar/);
|
||||
37
packages/liqe/test/liqe/createGetValueFunctionBody.ts
Normal file
37
packages/liqe/test/liqe/createGetValueFunctionBody.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
createGetValueFunctionBody,
|
||||
} from '../../src/createGetValueFunctionBody';
|
||||
|
||||
const testPath = (t, expected) => {
|
||||
t.is(createGetValueFunctionBody(t.title), expected);
|
||||
};
|
||||
|
||||
const testThrows = (t) => {
|
||||
t.throws(() => {
|
||||
createGetValueFunctionBody(t.title);
|
||||
});
|
||||
};
|
||||
|
||||
test('.a', testPath, 'return subject?.a');
|
||||
test('.a.b', testPath, 'return subject?.a?.b');
|
||||
|
||||
test('.foo', testPath, 'return subject?.foo');
|
||||
test('.foo.bar', testPath, 'return subject?.foo?.bar');
|
||||
|
||||
test('._foo', testPath, 'return subject?._foo');
|
||||
test('._foo._bar', testPath, 'return subject?._foo?._bar');
|
||||
|
||||
test('.foo0', testPath, 'return subject?.foo0');
|
||||
test('.foo0.bar1', testPath, 'return subject?.foo0?.bar1');
|
||||
|
||||
test('.1', testPath, 'return subject?.[1]');
|
||||
test('.10', testPath, 'return subject?.[10]');
|
||||
|
||||
test('foo', testThrows);
|
||||
test('.foo..bar', testThrows);
|
||||
test('.foo bar', testThrows);
|
||||
test('.foo[0]', testThrows);
|
||||
|
||||
test('.00', testThrows);
|
||||
test('.01', testThrows);
|
||||
149
packages/liqe/test/liqe/filter.ts
Normal file
149
packages/liqe/test/liqe/filter.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
filter,
|
||||
} from '../../src/filter';
|
||||
import {
|
||||
parse,
|
||||
} from '../../src/parse';
|
||||
|
||||
type Location = {
|
||||
city: string,
|
||||
};
|
||||
|
||||
type Person = {
|
||||
attributes?: Record<string, string | null>,
|
||||
balance?: number,
|
||||
email?: string,
|
||||
height: number,
|
||||
location?: Location,
|
||||
membership?: null,
|
||||
name: string,
|
||||
nick?: string,
|
||||
phoneNumber?: string,
|
||||
subscribed?: boolean,
|
||||
tags?: string[],
|
||||
};
|
||||
|
||||
const persons: readonly Person[] = [
|
||||
{
|
||||
height: 180,
|
||||
name: 'david',
|
||||
},
|
||||
{
|
||||
height: 175,
|
||||
name: 'john',
|
||||
},
|
||||
{
|
||||
height: 175,
|
||||
location: {
|
||||
city: 'London',
|
||||
},
|
||||
name: 'mike',
|
||||
},
|
||||
{
|
||||
height: 220,
|
||||
name: 'robert',
|
||||
tags: [
|
||||
'member',
|
||||
],
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
member: null,
|
||||
},
|
||||
balance: 6_364_917,
|
||||
email: 'noah@john.com',
|
||||
height: 225,
|
||||
membership: null,
|
||||
name: 'noah',
|
||||
nick: 'john',
|
||||
phoneNumber: '404-050-2611',
|
||||
subscribed: true,
|
||||
},
|
||||
{
|
||||
height: 150,
|
||||
name: 'foo bar',
|
||||
nick: 'old dog',
|
||||
},
|
||||
{
|
||||
height: 194,
|
||||
name: 'fox',
|
||||
nick: 'quick fox',
|
||||
},
|
||||
];
|
||||
|
||||
const testQuery = test.macro((t, expectedResultNames: string[]) => {
|
||||
const matchingPersonNames = filter(parse(t.title), persons).map((person) => {
|
||||
return person.name;
|
||||
});
|
||||
|
||||
t.deepEqual(matchingPersonNames, expectedResultNames);
|
||||
});
|
||||
|
||||
test('"david"', testQuery, ['david']);
|
||||
|
||||
test('name:"da"', testQuery, ['david']);
|
||||
test('name:"david"', testQuery, ['david']);
|
||||
test('name:David', testQuery, ['david']);
|
||||
|
||||
test('name:D*d', testQuery, ['david']);
|
||||
test('name:*avid', testQuery, ['david']);
|
||||
test('name:a*d', testQuery, ['david']);
|
||||
test('name:/(david)|(john)/', testQuery, ['david', 'john']);
|
||||
test('name:/(David)|(John)/', testQuery, []);
|
||||
test('name:/(David)|(John)/i', testQuery, ['david', 'john']);
|
||||
|
||||
test('height:[200 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test('height:[220 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test('height:{220 TO 300]', testQuery, ['noah']);
|
||||
test('height:[200 TO 225]', testQuery, ['robert', 'noah']);
|
||||
test('height:[200 TO 225}', testQuery, ['robert']);
|
||||
test('height:{220 TO 225}', testQuery, []);
|
||||
|
||||
test('NOT David', testQuery, ['john', 'mike', 'robert', 'noah', 'foo bar', 'fox']);
|
||||
test('-David', testQuery, ['john', 'mike', 'robert', 'noah', 'foo bar', 'fox']);
|
||||
test('David OR John', testQuery, ['david', 'john', 'noah']);
|
||||
test('Noah AND John', testQuery, ['noah']);
|
||||
test('John AND NOT Noah', testQuery, ['john']);
|
||||
test('David OR NOT John', testQuery, ['david', 'mike', 'robert', 'foo bar', 'fox']);
|
||||
test('John AND -Noah', testQuery, ['john']);
|
||||
test('David OR -John', testQuery, ['david', 'mike', 'robert', 'foo bar', 'fox']);
|
||||
|
||||
test('name:David OR John', testQuery, ['david', 'john', 'noah']);
|
||||
|
||||
test('name:David OR name:John', testQuery, ['david', 'john']);
|
||||
test('name:"david" OR name:"john"', testQuery, ['david', 'john']);
|
||||
test('name:"David" OR name:"John"', testQuery, []);
|
||||
|
||||
test('height:=175', testQuery, ['john', 'mike']);
|
||||
test('height:>200', testQuery, ['robert', 'noah']);
|
||||
test('height:>220', testQuery, ['noah']);
|
||||
test('height:>=220', testQuery, ['robert', 'noah']);
|
||||
|
||||
test('height:=175 AND NOT name:mike', testQuery, ['john']);
|
||||
|
||||
test('"member"', testQuery, ['robert']);
|
||||
|
||||
test('tags:"member"', testQuery, ['robert']);
|
||||
|
||||
test('"London"', testQuery, ['mike']);
|
||||
test('city:"London"', testQuery, []);
|
||||
test('location.city:"London"', testQuery, ['mike']);
|
||||
|
||||
test('membership:null', testQuery, ['noah']);
|
||||
test('attributes.member:null', testQuery, ['noah']);
|
||||
|
||||
test('subscribed:true', testQuery, ['noah']);
|
||||
|
||||
test('email:/[^.:@\\s](?:[^:@\\s]*[^.:@\\s])?@[^.@\\s]+(?:\\.[^.@\\s]+)*/', testQuery, ['noah']);
|
||||
|
||||
test('phoneNumber:"404-050-2611"', testQuery, ['noah']);
|
||||
test('phoneNumber:404', testQuery, ['noah']);
|
||||
|
||||
test('balance:364', testQuery, ['noah']);
|
||||
|
||||
test('(David)', testQuery, ['david']);
|
||||
test('(name:david OR name:john)', testQuery, ['david', 'john']);
|
||||
test('(name:"foo bar" AND nick:"quick fox") OR name:fox', testQuery, ['fox']);
|
||||
test('(name:fox OR name:"foo bar" AND nick:"old dog")', testQuery, ['foo bar']);
|
||||
test('(name:fox OR (name:"foo bar" AND nick:"old dog"))', testQuery, ['fox', 'foo bar']);
|
||||
346
packages/liqe/test/liqe/highlight.ts
Normal file
346
packages/liqe/test/liqe/highlight.ts
Normal file
@ -0,0 +1,346 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
highlight,
|
||||
} from '../../src/highlight';
|
||||
import {
|
||||
parse,
|
||||
} from '../../src/parse';
|
||||
import type {
|
||||
Highlight,
|
||||
} from '../../src/types';
|
||||
|
||||
const testQuery = test.macro(<T extends Object>(t, query: string, subject: T, highlights: Highlight[]) => {
|
||||
t.deepEqual(highlight(parse(query), subject), highlights);
|
||||
});
|
||||
|
||||
test.skip(
|
||||
'matches every property',
|
||||
testQuery,
|
||||
'*',
|
||||
{
|
||||
email: 'foo@bar.com',
|
||||
name: 'foo bar',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'email',
|
||||
query: /(foo@bar.com)/,
|
||||
},
|
||||
{
|
||||
keyword: /(foo bar)/,
|
||||
path: 'name',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches any property',
|
||||
testQuery,
|
||||
'foo',
|
||||
{
|
||||
email: 'foo@bar.com',
|
||||
name: 'foo bar',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'email',
|
||||
query: /(foo)/,
|
||||
},
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches property',
|
||||
testQuery,
|
||||
'name:foo',
|
||||
{
|
||||
name: 'foo bar',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches property (correctly handles case mismatch)',
|
||||
testQuery,
|
||||
'name:foo',
|
||||
{
|
||||
name: 'Foo Bar',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(Foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches or',
|
||||
testQuery,
|
||||
'name:foo OR name:bar OR height:=180',
|
||||
{
|
||||
height: 180,
|
||||
name: 'bar',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(bar)/,
|
||||
},
|
||||
{
|
||||
path: 'height',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches star (*) wildcard',
|
||||
testQuery,
|
||||
'name:f*o',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches star (*) wildcard (lazy)',
|
||||
testQuery,
|
||||
'name:f*o',
|
||||
{
|
||||
name: 'foo bar o baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches question mark (?) wildcard',
|
||||
testQuery,
|
||||
'name:f?o',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches regex',
|
||||
testQuery,
|
||||
'name:/foo/',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test.skip(
|
||||
'matches regex (multiple)',
|
||||
testQuery,
|
||||
'name:/(foo|bar)/g',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
{
|
||||
keyword: /(bar)/,
|
||||
path: 'name',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches number',
|
||||
testQuery,
|
||||
'height:=180',
|
||||
{
|
||||
height: 180,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'height',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches range',
|
||||
testQuery,
|
||||
'height:[100 TO 200]',
|
||||
{
|
||||
height: 180,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'height',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches boolean',
|
||||
testQuery,
|
||||
'member:false',
|
||||
{
|
||||
member: false,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'member',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches array member',
|
||||
testQuery,
|
||||
'tags:bar',
|
||||
{
|
||||
tags: [
|
||||
'foo',
|
||||
'bar',
|
||||
'baz qux',
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'tags.1',
|
||||
query: /(bar)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'matches multiple array members',
|
||||
testQuery,
|
||||
'tags:ba',
|
||||
{
|
||||
tags: [
|
||||
'foo',
|
||||
'bar',
|
||||
'baz qux',
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'tags.1',
|
||||
query: /(ba)/,
|
||||
},
|
||||
{
|
||||
path: 'tags.2',
|
||||
query: /(ba)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test.skip(
|
||||
'does not include highlights from non-matching branches (and)',
|
||||
testQuery,
|
||||
'name:foo AND NOT name:foo',
|
||||
{
|
||||
name: 'foo',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
test(
|
||||
'does not include highlights from non-matching branches (or)',
|
||||
testQuery,
|
||||
'name:bar OR NOT name:foo',
|
||||
{
|
||||
name: 'foo',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
test(
|
||||
'does not highlight the same term multiple times',
|
||||
testQuery,
|
||||
'foo',
|
||||
{
|
||||
name: 'foo foo foo',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'aggregates multiple highlights',
|
||||
testQuery,
|
||||
'foo AND bar AND baz',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo|bar|baz)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'aggregates multiple highlights (phrases)',
|
||||
testQuery,
|
||||
'"foo bar" AND baz',
|
||||
{
|
||||
name: 'foo bar baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(foo bar|baz)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'aggregates multiple highlights (escaping)',
|
||||
testQuery,
|
||||
'"(foo bar)" AND baz',
|
||||
{
|
||||
name: '(foo bar) baz',
|
||||
},
|
||||
[
|
||||
{
|
||||
path: 'name',
|
||||
query: /(\(foo bar\)|baz)/,
|
||||
},
|
||||
],
|
||||
);
|
||||
104
packages/liqe/test/liqe/hydrateAst.ts
Normal file
104
packages/liqe/test/liqe/hydrateAst.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
hydrateAst,
|
||||
} from '../../src/hydrateAst';
|
||||
import type {
|
||||
LiqeQuery,
|
||||
} from '../../src/types';
|
||||
|
||||
test('adds getValue when field is a safe path', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
name: '.foo',
|
||||
type: 'Field',
|
||||
},
|
||||
type: 'Tag',
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.true('getValue' in hydratedAst);
|
||||
});
|
||||
|
||||
test('adds getValue when field is a safe path (recursive)', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
type: 'ImplicitField',
|
||||
},
|
||||
left: {
|
||||
field: {
|
||||
type: 'ImplicitField',
|
||||
},
|
||||
right: {
|
||||
field: {
|
||||
type: 'ImplicitField',
|
||||
},
|
||||
operand: {
|
||||
field: {
|
||||
name: '.foo',
|
||||
type: 'Field',
|
||||
},
|
||||
type: 'Tag',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.true('getValue' in (hydratedAst?.left?.right?.operand ?? {}));
|
||||
});
|
||||
|
||||
test('does not add getValue if path is unsafe', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
name: 'foo',
|
||||
type: 'Field',
|
||||
},
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.false('getValue' in hydratedAst);
|
||||
});
|
||||
|
||||
test('getValue accesses existing value', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
name: '.foo',
|
||||
type: 'Field',
|
||||
},
|
||||
type: 'Tag',
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.is(hydratedAst.getValue?.({foo: 'bar'}), 'bar');
|
||||
});
|
||||
|
||||
test('getValue accesses existing value (deep)', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
name: '.foo.bar.baz',
|
||||
type: 'Field',
|
||||
},
|
||||
type: 'Tag',
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.is(hydratedAst.getValue?.({foo: {bar: {baz: 'qux'}}}), 'qux');
|
||||
});
|
||||
|
||||
test('returns undefined if path does not resolve', (t) => {
|
||||
const parserAst = {
|
||||
field: {
|
||||
name: '.foo.bar.baz',
|
||||
type: 'Field',
|
||||
},
|
||||
} as LiqeQuery;
|
||||
|
||||
const hydratedAst = hydrateAst(parserAst);
|
||||
|
||||
t.is(hydratedAst.getValue?.({}), undefined);
|
||||
});
|
||||
32
packages/liqe/test/liqe/isSafePath.ts
Normal file
32
packages/liqe/test/liqe/isSafePath.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
isSafePath,
|
||||
} from '../../src/isSafePath';
|
||||
|
||||
const testPath = (t, expected) => {
|
||||
t.is(isSafePath(t.title), expected);
|
||||
};
|
||||
|
||||
test('.a', testPath, true);
|
||||
test('.a.b', testPath, true);
|
||||
|
||||
test('.foo', testPath, true);
|
||||
test('.foo.bar', testPath, true);
|
||||
|
||||
test('._foo', testPath, true);
|
||||
test('._foo._bar', testPath, true);
|
||||
|
||||
test('.foo0', testPath, true);
|
||||
test('.foo0.bar1', testPath, true);
|
||||
|
||||
test('.1', testPath, true);
|
||||
test('.10', testPath, true);
|
||||
|
||||
test('foo', testPath, false);
|
||||
test('.foo..bar', testPath, false);
|
||||
test('.foo bar', testPath, false);
|
||||
test('.foo[0]', testPath, false);
|
||||
|
||||
test('.00', testPath, false);
|
||||
test('.01', testPath, false);
|
||||
|
||||
12
packages/liqe/test/liqe/isSafeUnquotedExpression.ts
Normal file
12
packages/liqe/test/liqe/isSafeUnquotedExpression.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
isSafeUnquotedExpression,
|
||||
} from '../../src/isSafeUnquotedExpression';
|
||||
|
||||
const testExpression = (t, expected) => {
|
||||
t.is(isSafeUnquotedExpression(t.title), expected);
|
||||
};
|
||||
|
||||
test('foo', testExpression, true);
|
||||
|
||||
test('.foo', testExpression, false);
|
||||
2564
packages/liqe/test/liqe/parse.ts
Normal file
2564
packages/liqe/test/liqe/parse.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/liqe/test/liqe/parseRegex.ts
Normal file
18
packages/liqe/test/liqe/parseRegex.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
parseRegex,
|
||||
} from '../../src/parseRegex';
|
||||
|
||||
const EMAIL_REGEX = /[^.:@\\s](?:[^:@\\s]*[^.:@\\s])?@[^.@\\s]+(?:\\.[^.@\\s]+)*/;
|
||||
|
||||
const testRule = test.macro((t, regex: RegExp) => {
|
||||
t.deepEqual(parseRegex(t.title), regex);
|
||||
});
|
||||
|
||||
test('/foo/', testRule, /foo/);
|
||||
test('/foo/u', testRule, /foo/u);
|
||||
test('/foo', testRule, /\/foo/);
|
||||
test('foo/bar', testRule, /foo\/bar/);
|
||||
test('/foo/bar/', testRule, /foo\/bar/);
|
||||
test(String(EMAIL_REGEX), testRule, EMAIL_REGEX);
|
||||
|
||||
133
packages/liqe/test/liqe/serialize.ts
Normal file
133
packages/liqe/test/liqe/serialize.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
parse,
|
||||
} from '../../src/parse';
|
||||
import {
|
||||
serialize,
|
||||
} from '../../src/serialize';
|
||||
|
||||
const testQuery = (t) => {
|
||||
t.is(serialize(parse(t.title)), t.title);
|
||||
};
|
||||
|
||||
test('empty query', (t) => {
|
||||
t.is(serialize(parse('')), '');
|
||||
});
|
||||
|
||||
test('foo', testQuery);
|
||||
|
||||
test('()', testQuery);
|
||||
|
||||
test('( )', testQuery);
|
||||
|
||||
test('foo:', testQuery);
|
||||
|
||||
test('foo bar', testQuery);
|
||||
|
||||
test('foo AND bar [multiple spaces]', (t) => {
|
||||
t.is(serialize(parse('foo AND bar')), 'foo AND bar');
|
||||
});
|
||||
|
||||
test('foo bar [multiple spaces]', (t) => {
|
||||
t.is(serialize(parse('foo bar')), 'foo bar');
|
||||
});
|
||||
|
||||
test('foo_bar', testQuery);
|
||||
|
||||
test('"foo"', testQuery);
|
||||
|
||||
test('\'foo\'', testQuery);
|
||||
|
||||
test('/foo/', testQuery);
|
||||
|
||||
test('/foo/ui', testQuery);
|
||||
|
||||
test('/\\s/', testQuery);
|
||||
|
||||
test('/[^.:@\\s](?:[^:@\\s]*[^.:@\\s])?@[^.@\\s]+(?:\\.[^.@\\s]+)*/', testQuery);
|
||||
|
||||
test('foo:bar', testQuery);
|
||||
|
||||
// https://github.com/gajus/liqe/issues/18
|
||||
// https://github.com/gajus/liqe/issues/19
|
||||
test.skip('foo: bar', testQuery);
|
||||
|
||||
test('foo:123', testQuery);
|
||||
|
||||
test('foo:=123', testQuery);
|
||||
|
||||
// https://github.com/gajus/liqe/issues/18
|
||||
// https://github.com/gajus/liqe/issues/19
|
||||
test.skip('foo:= 123', testQuery);
|
||||
|
||||
test('foo:=-123', testQuery);
|
||||
|
||||
test('foo:=123.4', testQuery);
|
||||
|
||||
test('foo:>=123', testQuery);
|
||||
|
||||
test('foo:true', testQuery);
|
||||
|
||||
test('foo:false', testQuery);
|
||||
|
||||
test('foo:null', testQuery);
|
||||
|
||||
test('foo.bar:baz', testQuery);
|
||||
|
||||
test('foo_bar:baz', testQuery);
|
||||
|
||||
test('$foo:baz', testQuery);
|
||||
|
||||
test('"foo bar":baz', testQuery);
|
||||
|
||||
test('\'foo bar\':baz', testQuery);
|
||||
|
||||
test('foo:"bar"', testQuery);
|
||||
|
||||
test('foo:\'bar\'', testQuery);
|
||||
|
||||
test('foo:bar baz:qux', testQuery);
|
||||
|
||||
test('foo:bar AND baz:qux', testQuery);
|
||||
|
||||
test('(foo:bar AND baz:qux)', testQuery);
|
||||
|
||||
test('(foo:bar) AND (baz:qux)', testQuery);
|
||||
|
||||
test('NOT (foo:bar AND baz:qux)', testQuery);
|
||||
|
||||
test('NOT foo:bar', testQuery);
|
||||
|
||||
test('-foo:bar', testQuery);
|
||||
|
||||
test('NOT (foo:bar)', testQuery);
|
||||
|
||||
test('(foo:bar AND NOT baz:qux)', testQuery);
|
||||
|
||||
test('foo:bar AND baz:qux AND quuz:corge', testQuery);
|
||||
|
||||
test('((foo:bar AND baz:qux) AND quuz:corge)', testQuery);
|
||||
|
||||
test('(foo:bar)', testQuery);
|
||||
|
||||
test('((foo:bar))', testQuery);
|
||||
|
||||
test('( foo:bar )', testQuery);
|
||||
|
||||
test('( foo:bar ) [multiple spaces]', (t) => {
|
||||
t.is(serialize(parse('( foo:bar )')), '( foo:bar )');
|
||||
});
|
||||
|
||||
test('(foo:bar OR baz:qux)', testQuery);
|
||||
|
||||
test('(foo:bar OR (baz:qux OR quuz:corge))', testQuery);
|
||||
|
||||
test('((foo:bar OR baz:qux) OR quuz:corge)', testQuery);
|
||||
|
||||
test('[1 TO 2]', testQuery);
|
||||
|
||||
test('{1 TO 2]', testQuery);
|
||||
|
||||
test('[1 TO 2}', testQuery);
|
||||
|
||||
test('{1 TO 2}', testQuery);
|
||||
21
packages/liqe/test/liqe/test.ts
Normal file
21
packages/liqe/test/liqe/test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import test from 'ava';
|
||||
import {
|
||||
parse,
|
||||
} from '../../src/parse';
|
||||
import {
|
||||
test as liqeTest,
|
||||
} from '../../src/test';
|
||||
|
||||
test('returns true if subject matches the query', (t) => {
|
||||
t.true(liqeTest(parse('david'), {
|
||||
height: 180,
|
||||
name: 'david',
|
||||
}));
|
||||
});
|
||||
|
||||
test('returns false if subject does not match the query', (t) => {
|
||||
t.false(liqeTest(parse('mike'), {
|
||||
height: 180,
|
||||
name: 'david',
|
||||
}));
|
||||
});
|
||||
26
packages/liqe/tsconfig.json
Normal file
26
packages/liqe/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": false,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "es5"
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
"src",
|
||||
"test"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user