Merge pull request #3216 from omnivore-app/fix/non-english-word-in-search

capture non-english words in parser
This commit is contained in:
Hongbo Wu
2023-12-08 10:45:09 +08:00
committed by GitHub
50 changed files with 8905 additions and 132 deletions

View File

@ -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"]

View File

@ -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"]

View File

@ -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",

View File

@ -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'

View File

@ -1,4 +1,4 @@
import { LiqeQuery, parse } from 'liqe'
import { LiqeQuery, parse } from '@omnivore/liqe'
export const parseSearchQuery = (query: string): LiqeQuery => {
const searchQuery = query

View File

@ -0,0 +1 @@
src/grammar.ts

14
packages/liqe/.eslintrc Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
/src
/test
/coverage
/benchmark
.*
*.log

1
packages/liqe/.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

4
packages/liqe/.nycrc Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true
}

24
packages/liqe/LICENSE Normal file
View 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
View 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)

View 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
View 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';

View 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 ? '(.+?)' : '(.)';
}),
);
};

View 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, '?.');
};

View 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'));
}
};

View 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);
}
}

View 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');
};

View 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,
);
};

View 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('') %}

View 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;

View 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,
};
});
};

View 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;
};

View 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.');
};

View 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);
};

View File

@ -0,0 +1,3 @@
export const isSafeUnquotedExpression = (expression: string): boolean => {
return /^[#$*@A-Z_a-z][#$*.@A-Z_a-z-]*$/.test(expression);
};

View 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;
};

View 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]);
};

View 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
View 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;
};

View 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}`);
}
};

View 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
View 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;

View 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(),
);

View File

@ -0,0 +1,5 @@
{
"rules": {
"id-length": 0
}
}

View 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/);

View 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);

View 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']);

View 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)/,
},
],
);

View 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);
});

View 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);

View 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);

File diff suppressed because it is too large Load Diff

View 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);

View 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);

View 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',
}));
});

View 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"
]
}

3283
yarn.lock

File diff suppressed because it is too large Load Diff