@travetto/model-query-language 5.0.0-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/__index__.ts +1 -0
- package/package.json +47 -0
- package/src/parser.ts +198 -0
- package/src/tokenizer.ts +186 -0
- package/src/types.ts +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 ArcSine Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!-- This file was generated by @travetto/doc and should not be modified directly -->
|
|
2
|
+
<!-- Please modify https://github.com/travetto/travetto/tree/main/module/model-query-language/DOC.tsx and execute "npx trv doc" to rebuild -->
|
|
3
|
+
# Data Model Query Language
|
|
4
|
+
|
|
5
|
+
## Datastore query language.
|
|
6
|
+
|
|
7
|
+
**Install: @travetto/model-query-language**
|
|
8
|
+
```bash
|
|
9
|
+
npm install @travetto/model-query-language
|
|
10
|
+
|
|
11
|
+
# or
|
|
12
|
+
|
|
13
|
+
yarn add @travetto/model-query-language
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This module provides a textual query language for the [Data Model Querying](https://github.com/travetto/travetto/tree/main/module/model-query#readme "Datastore abstraction for advanced query support.") interface. The language itself is fairly simple, boolean logic, with parenthetical support.The operators supported are:
|
|
17
|
+
|
|
18
|
+
## Query Language
|
|
19
|
+
|
|
20
|
+
* `<`, `<=` - Less than, and less than or equal to
|
|
21
|
+
* `>`, `>=` - Greater than, and greater than or equal to
|
|
22
|
+
* `!=`, `==` - Not equal to, and equal to
|
|
23
|
+
* `~` - Matches regular expression, supports the `i` flag to trigger case insensitive searches
|
|
24
|
+
* `!`, `not` - Negates a clause
|
|
25
|
+
* `in`, `not-in` - Supports checking if a field is in a list of literal values
|
|
26
|
+
* `and`, `&&` - Intersection of clauses
|
|
27
|
+
* `or`, `||` - Union of clauses
|
|
28
|
+
All sub fields are dot separated for access, e.g. `user.address.city`.A query language version of the previous query could look like:
|
|
29
|
+
|
|
30
|
+
**Code: Query language with boolean checks and exists check**
|
|
31
|
+
```sql
|
|
32
|
+
not (age < 35) and contact != null
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A more complex query would look like:
|
|
36
|
+
|
|
37
|
+
**Code: Query language with more complex needs**
|
|
38
|
+
```sql
|
|
39
|
+
user.role in ['admin', 'root'] && (user.address.state == 'VA' || user.address.city == 'Springfield')
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Regular Expression
|
|
43
|
+
When querying with regular expressions, patterns can be specified as `'strings'` or as `/patterns/`. The latter allows for the case insensitive modifier: `/pattern/i`. Supporting the insensitive flag is up to the underlying model implementation.
|
package/__index__.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/parser';
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@travetto/model-query-language",
|
|
3
|
+
"version": "5.0.0-rc.9",
|
|
4
|
+
"description": "Datastore query language.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"datastore",
|
|
7
|
+
"schema",
|
|
8
|
+
"typescript",
|
|
9
|
+
"travetto",
|
|
10
|
+
"query-language"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://travetto.io",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"email": "travetto.framework@gmail.com",
|
|
16
|
+
"name": "Travetto Framework"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"__index__.ts",
|
|
20
|
+
"src",
|
|
21
|
+
"support"
|
|
22
|
+
],
|
|
23
|
+
"main": "__index__.ts",
|
|
24
|
+
"repository": {
|
|
25
|
+
"url": "https://github.com/travetto/travetto.git",
|
|
26
|
+
"directory": "module/model-query"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@travetto/model-query": "^5.0.0-rc.9",
|
|
30
|
+
"@travetto/model": "^5.0.0-rc.9",
|
|
31
|
+
"@travetto/schema": "^5.0.0-rc.9"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@travetto/test": "^5.0.0-rc.9"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@travetto/test": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"travetto": {
|
|
42
|
+
"displayName": "Data Model Query Language"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { castTo } from '@travetto/runtime';
|
|
2
|
+
import { WhereClauseRaw } from '@travetto/model-query';
|
|
3
|
+
import { QueryLanguageTokenizer } from './tokenizer';
|
|
4
|
+
import { Token, Literal, GroupNode, OP_TRANSLATION, ArrayNode, AllNode } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Determine if a token is boolean
|
|
8
|
+
*/
|
|
9
|
+
function isBoolean(o: unknown): o is Token & { type: 'boolean' } {
|
|
10
|
+
return !!o && typeof o === 'object' && 'type' in o && o.type === 'boolean';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Language parser
|
|
15
|
+
*/
|
|
16
|
+
export class QueryLanguageParser {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle all clauses
|
|
20
|
+
*/
|
|
21
|
+
static handleClause(nodes: (AllNode | Token)[]): void {
|
|
22
|
+
const val: Token | ArrayNode = castTo(nodes.pop());
|
|
23
|
+
const op: Token & { value: string } = castTo(nodes.pop());
|
|
24
|
+
const ident: Token & { value: string } = castTo(nodes.pop());
|
|
25
|
+
|
|
26
|
+
// value isn't a literal or a list, bail
|
|
27
|
+
if (val.type !== 'literal' && val.type !== 'list') {
|
|
28
|
+
throw new Error(`Unexpected token: ${val.value}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// If operator is not an operator, bail
|
|
32
|
+
if (op.type !== 'operator') {
|
|
33
|
+
throw new Error(`Unexpected token: ${op.value}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If operator is not known, bail
|
|
37
|
+
const finalOp = OP_TRANSLATION[op.value];
|
|
38
|
+
|
|
39
|
+
if (!finalOp) {
|
|
40
|
+
throw new Error(`Unexpected operator: ${op.value}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
nodes.push({
|
|
44
|
+
type: 'clause',
|
|
45
|
+
field: ident.value,
|
|
46
|
+
op: finalOp,
|
|
47
|
+
value: val.value
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Handle unary support
|
|
51
|
+
this.unary(nodes);
|
|
52
|
+
// Simplify as we go along
|
|
53
|
+
this.condense(nodes, 'and');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Condense nodes to remove unnecessary groupings
|
|
58
|
+
* (a AND (b AND (c AND d))) => (a AND b AND c)
|
|
59
|
+
*/
|
|
60
|
+
static condense(nodes: (AllNode | Token)[], op: 'and' | 'or'): void {
|
|
61
|
+
let second = nodes[nodes.length - 2];
|
|
62
|
+
|
|
63
|
+
while (isBoolean(second) && second.value === op) {
|
|
64
|
+
const right: AllNode = castTo(nodes.pop());
|
|
65
|
+
nodes.pop()!;
|
|
66
|
+
const left: AllNode = castTo(nodes.pop());
|
|
67
|
+
const rg: GroupNode = castTo(right);
|
|
68
|
+
if (rg.type === 'group' && rg.op === op) {
|
|
69
|
+
rg.value.unshift(left);
|
|
70
|
+
nodes.push(rg);
|
|
71
|
+
} else {
|
|
72
|
+
nodes.push({
|
|
73
|
+
type: 'group',
|
|
74
|
+
op,
|
|
75
|
+
value: [left, right]
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
second = nodes[nodes.length - 2];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Remove unnecessary unary nodes
|
|
84
|
+
* (((5))) => 5
|
|
85
|
+
*/
|
|
86
|
+
static unary(nodes: (AllNode | Token)[]): void {
|
|
87
|
+
const second = nodes[nodes.length - 2];
|
|
88
|
+
if (second && second.type === 'unary' && second.value === 'not') {
|
|
89
|
+
const node = nodes.pop();
|
|
90
|
+
nodes.pop();
|
|
91
|
+
nodes.push({
|
|
92
|
+
type: 'unary',
|
|
93
|
+
op: 'not',
|
|
94
|
+
value: castTo(node)
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse all tokens
|
|
101
|
+
*/
|
|
102
|
+
static parse(tokens: Token[], pos: number = 0): AllNode {
|
|
103
|
+
|
|
104
|
+
let top: (AllNode | Token)[] = [];
|
|
105
|
+
const stack: (typeof top)[] = [top];
|
|
106
|
+
let arr: Literal[] | undefined;
|
|
107
|
+
|
|
108
|
+
let token = tokens[pos];
|
|
109
|
+
while (token) {
|
|
110
|
+
switch (token.type) {
|
|
111
|
+
case 'grouping':
|
|
112
|
+
if (token.value === 'start') {
|
|
113
|
+
stack.push(top = []);
|
|
114
|
+
} else {
|
|
115
|
+
const group = stack.pop()!;
|
|
116
|
+
top = stack[stack.length - 1];
|
|
117
|
+
this.condense(group, 'or');
|
|
118
|
+
top.push(group[0]);
|
|
119
|
+
this.unary(top);
|
|
120
|
+
this.condense(top, 'and');
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case 'array':
|
|
124
|
+
if (token.value === 'start') {
|
|
125
|
+
arr = [];
|
|
126
|
+
} else {
|
|
127
|
+
const arrNode: ArrayNode = { type: 'list', value: arr! };
|
|
128
|
+
top.push(arrNode);
|
|
129
|
+
arr = undefined;
|
|
130
|
+
this.handleClause(top);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
case 'literal':
|
|
134
|
+
if (arr !== undefined) {
|
|
135
|
+
arr.push(token.value);
|
|
136
|
+
} else {
|
|
137
|
+
top.push(token);
|
|
138
|
+
this.handleClause(top);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case 'punctuation':
|
|
142
|
+
if (!arr) {
|
|
143
|
+
throw new Error(`Invalid token: ${token.value}`);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
top.push(token);
|
|
148
|
+
}
|
|
149
|
+
token = tokens[++pos];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.condense(top, 'or');
|
|
153
|
+
|
|
154
|
+
return castTo(top[0]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert Query AST to output
|
|
159
|
+
*/
|
|
160
|
+
static convert<T = unknown>(node: AllNode): WhereClauseRaw<T> {
|
|
161
|
+
switch (node.type) {
|
|
162
|
+
case 'unary': {
|
|
163
|
+
return castTo({ [`$${node.op!}`]: this.convert(node.value) });
|
|
164
|
+
}
|
|
165
|
+
case 'group': {
|
|
166
|
+
return castTo({ [`$${node.op!}`]: node.value.map(x => this.convert(x)) });
|
|
167
|
+
}
|
|
168
|
+
case 'clause': {
|
|
169
|
+
const parts = node.field!.split('.');
|
|
170
|
+
const top: WhereClauseRaw<T> = {};
|
|
171
|
+
let sub: Record<string, unknown> = top;
|
|
172
|
+
for (const p of parts) {
|
|
173
|
+
sub = sub[p] = {};
|
|
174
|
+
}
|
|
175
|
+
if (node.op === '$regex' && typeof node.value === 'string') {
|
|
176
|
+
sub[node.op!] = new RegExp(`^${node.value}`);
|
|
177
|
+
} else if ((node.op === '$eq' || node.op === '$ne') && node.value === null) {
|
|
178
|
+
sub.$exists = node.op !== '$eq';
|
|
179
|
+
} else if ((node.op === '$in' || node.op === '$nin') && !Array.isArray(node.value)) {
|
|
180
|
+
throw new Error(`Expected array literal for ${node.op}`);
|
|
181
|
+
} else {
|
|
182
|
+
sub[node.op!] = node.value;
|
|
183
|
+
}
|
|
184
|
+
return top;
|
|
185
|
+
}
|
|
186
|
+
default: throw new Error(`Unexpected node type: ${node.type}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Tokenize and parse text
|
|
192
|
+
*/
|
|
193
|
+
static parseToQuery<T = unknown>(text: string): WhereClauseRaw<T> {
|
|
194
|
+
const tokens = QueryLanguageTokenizer.tokenize(text);
|
|
195
|
+
const node = this.parse(tokens);
|
|
196
|
+
return this.convert(node);
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/tokenizer.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { TimeUtil } from '@travetto/runtime';
|
|
2
|
+
import { Token, TokenizeState, TokenType } from './types';
|
|
3
|
+
|
|
4
|
+
const OPEN_PARENS = 0x28, CLOSE_PARENS = 0x29, OPEN_BRACKET = 0x5b, CLOSE_BRACKET = 0x5d, COMMA = 0x2c;
|
|
5
|
+
const GREATER_THAN = 0x3e, LESS_THAN = 0x3c, EQUAL = 0x3d, NOT = 0x21, MODULO = 0x25, TILDE = 0x7e, AND = 0x26, OR = 0x7c;
|
|
6
|
+
const SPACE = 0x20, TAB = 0x09;
|
|
7
|
+
const DBL_QUOTE = 0x22, SGL_QUOTE = 0x27, FORWARD_SLASH = 0x2f, BACKSLASH = 0x5c;
|
|
8
|
+
const PERIOD = 0x2e, UNDERSCORE = 0x54, DOLLAR_SIGN = 0x24, DASH = 0x2d;
|
|
9
|
+
const ZERO = 0x30, NINE = 0x39, UPPER_A = 0x41, UPPER_Z = 0x5a, LOWER_A = 0x61, LOWER_Z = 0x7a;
|
|
10
|
+
const LOWER_I = 0x69, LOWER_G = 0x67, LOWER_M = 0x6d, LOWER_S = 0x73;
|
|
11
|
+
|
|
12
|
+
const ESCAPE: Record<string, string> = {
|
|
13
|
+
'\\n': '\n',
|
|
14
|
+
'\\r': '\r',
|
|
15
|
+
'\\t': '\t',
|
|
16
|
+
'\\"': '"',
|
|
17
|
+
"\\'": "'"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mapping of keywords to node types and values
|
|
22
|
+
*/
|
|
23
|
+
const TOKEN_MAPPING: Record<string, Token> = {
|
|
24
|
+
and: { type: 'boolean', value: 'and' },
|
|
25
|
+
'&&': { type: 'boolean', value: 'and' },
|
|
26
|
+
or: { type: 'boolean', value: 'or' },
|
|
27
|
+
'||': { type: 'boolean', value: 'or' },
|
|
28
|
+
in: { type: 'operator', value: 'in' },
|
|
29
|
+
['not-in']: { type: 'operator', value: 'not-in' },
|
|
30
|
+
not: { type: 'unary', value: 'not' },
|
|
31
|
+
'[': { type: 'array', value: 'start' },
|
|
32
|
+
']': { type: 'array', value: 'end' },
|
|
33
|
+
'(': { type: 'grouping', value: 'start' },
|
|
34
|
+
')': { type: 'grouping', value: 'end' },
|
|
35
|
+
null: { type: 'literal', value: null },
|
|
36
|
+
true: { type: 'literal', value: true },
|
|
37
|
+
false: { type: 'literal', value: false },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tokenizer for the query language
|
|
42
|
+
*/
|
|
43
|
+
export class QueryLanguageTokenizer {
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Process the next token. Can specify expected type as needed
|
|
47
|
+
*/
|
|
48
|
+
static #processToken(state: TokenizeState, mode?: TokenType): Token {
|
|
49
|
+
const text = state.text.substring(state.start, state.pos);
|
|
50
|
+
const res = TOKEN_MAPPING[text.toLowerCase()];
|
|
51
|
+
let value: unknown = text;
|
|
52
|
+
if (!res && state.mode === 'literal') {
|
|
53
|
+
if (/^["']/.test(text)) {
|
|
54
|
+
value = text.substring(1, text.length - 1)
|
|
55
|
+
.replace(/\\[.]/g, (a, b) => ESCAPE[a] || b);
|
|
56
|
+
} else if (/^\//.test(text)) {
|
|
57
|
+
const start = 1;
|
|
58
|
+
const end = text.lastIndexOf('/');
|
|
59
|
+
value = new RegExp(text.substring(start, end), text.substring(end + 1));
|
|
60
|
+
} else if (/^-?\d+$/.test(text)) {
|
|
61
|
+
value = parseInt(text, 10);
|
|
62
|
+
} else if (/^-?\d+[.]\d+$/.test(text)) {
|
|
63
|
+
value = parseFloat(text);
|
|
64
|
+
} else if (TimeUtil.isTimeSpan(text)) {
|
|
65
|
+
value = text;
|
|
66
|
+
} else {
|
|
67
|
+
state.mode = 'identifier';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return res ?? { value, type: state.mode || mode };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flush state to output
|
|
75
|
+
*/
|
|
76
|
+
static #flush(state: TokenizeState, mode?: TokenType): void {
|
|
77
|
+
if ((!mode || !state.mode || mode !== state.mode) && state.start !== state.pos) {
|
|
78
|
+
if (state.mode !== 'whitespace') {
|
|
79
|
+
state.out.push(this.#processToken(state, mode));
|
|
80
|
+
}
|
|
81
|
+
state.start = state.pos;
|
|
82
|
+
}
|
|
83
|
+
state.mode = mode || state.mode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Determine if valid regex flag
|
|
88
|
+
*/
|
|
89
|
+
static #isValidRegexFlag(ch: number): boolean {
|
|
90
|
+
return ch === LOWER_I || ch === LOWER_G || ch === LOWER_M || ch === LOWER_S;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Determine if valid token identifier
|
|
95
|
+
*/
|
|
96
|
+
static #isValidIdentToken(ch: number): boolean {
|
|
97
|
+
return (ch >= ZERO && ch <= NINE) ||
|
|
98
|
+
(ch >= UPPER_A && ch <= UPPER_Z) ||
|
|
99
|
+
(ch >= LOWER_A && ch <= LOWER_Z) ||
|
|
100
|
+
(ch === UNDERSCORE) ||
|
|
101
|
+
(ch === DASH) ||
|
|
102
|
+
(ch === DOLLAR_SIGN) ||
|
|
103
|
+
(ch === PERIOD);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read string until quote
|
|
108
|
+
*/
|
|
109
|
+
static readString(text: string, pos: number): number {
|
|
110
|
+
const len = text.length;
|
|
111
|
+
const ch = text.charCodeAt(pos);
|
|
112
|
+
const q = ch;
|
|
113
|
+
pos += 1;
|
|
114
|
+
while (pos < len) {
|
|
115
|
+
if (text.charCodeAt(pos) === q) {
|
|
116
|
+
break;
|
|
117
|
+
} else if (text.charCodeAt(pos) === BACKSLASH) {
|
|
118
|
+
pos += 1;
|
|
119
|
+
}
|
|
120
|
+
pos += 1;
|
|
121
|
+
}
|
|
122
|
+
if (pos === len && text.charCodeAt(pos) !== q) {
|
|
123
|
+
throw new Error('Unterminated string literal');
|
|
124
|
+
}
|
|
125
|
+
return pos;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tokenize a text string
|
|
130
|
+
*/
|
|
131
|
+
static tokenize(text: string): Token[] {
|
|
132
|
+
const state: TokenizeState = {
|
|
133
|
+
out: [],
|
|
134
|
+
pos: 0,
|
|
135
|
+
start: 0,
|
|
136
|
+
text,
|
|
137
|
+
mode: undefined!
|
|
138
|
+
};
|
|
139
|
+
const len = text.length;
|
|
140
|
+
// Loop through each char
|
|
141
|
+
while (state.pos < len) {
|
|
142
|
+
// Read code as a number, more efficient
|
|
143
|
+
const ch = text.charCodeAt(state.pos);
|
|
144
|
+
switch (ch) {
|
|
145
|
+
// Handle punctuation
|
|
146
|
+
case OPEN_PARENS: case CLOSE_PARENS: case OPEN_BRACKET: case CLOSE_BRACKET: case COMMA:
|
|
147
|
+
this.#flush(state);
|
|
148
|
+
state.mode = 'punctuation';
|
|
149
|
+
break;
|
|
150
|
+
// Handle operator
|
|
151
|
+
case GREATER_THAN: case LESS_THAN: case EQUAL:
|
|
152
|
+
case MODULO: case NOT: case TILDE: case AND: case OR:
|
|
153
|
+
this.#flush(state, 'operator');
|
|
154
|
+
break;
|
|
155
|
+
// Handle whitespace
|
|
156
|
+
case SPACE: case TAB:
|
|
157
|
+
this.#flush(state, 'whitespace');
|
|
158
|
+
break;
|
|
159
|
+
// Handle quotes and slashes
|
|
160
|
+
case DBL_QUOTE: case SGL_QUOTE: case FORWARD_SLASH:
|
|
161
|
+
this.#flush(state);
|
|
162
|
+
state.mode = 'literal';
|
|
163
|
+
state.pos = this.readString(text, state.pos) + 1;
|
|
164
|
+
if (ch === FORWARD_SLASH) { // Read modifiers, not used by all, but useful in general
|
|
165
|
+
while (this.#isValidRegexFlag(text.charCodeAt(state.pos))) {
|
|
166
|
+
state.pos += 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.#flush(state);
|
|
170
|
+
continue;
|
|
171
|
+
// Handle literal
|
|
172
|
+
default:
|
|
173
|
+
if (this.#isValidIdentToken(ch)) {
|
|
174
|
+
this.#flush(state, 'literal');
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error(`Invalid character: ${text.substring(Math.max(0, state.pos - 10), state.pos + 1)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
state.pos += 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#flush(state);
|
|
183
|
+
|
|
184
|
+
return state.out;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported token types
|
|
3
|
+
*/
|
|
4
|
+
export type TokenType =
|
|
5
|
+
'literal' | 'identifier' | 'boolean' |
|
|
6
|
+
'operator' | 'grouping' | 'array' |
|
|
7
|
+
'whitespace' | 'punctuation' | 'unary';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tokenization state
|
|
11
|
+
*/
|
|
12
|
+
export interface TokenizeState {
|
|
13
|
+
out: Token[];
|
|
14
|
+
pos: number;
|
|
15
|
+
start: number;
|
|
16
|
+
text: string;
|
|
17
|
+
mode: TokenType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Literal types
|
|
22
|
+
*/
|
|
23
|
+
export type Literal = boolean | null | string | number | RegExp | Date;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Token
|
|
27
|
+
*/
|
|
28
|
+
export interface Token {
|
|
29
|
+
type: TokenType;
|
|
30
|
+
value: Literal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Base AST Node
|
|
35
|
+
*/
|
|
36
|
+
export interface Node<T extends string = string> {
|
|
37
|
+
type: T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Simple clause
|
|
42
|
+
*/
|
|
43
|
+
export interface ClauseNode extends Node<'clause'> {
|
|
44
|
+
field?: string;
|
|
45
|
+
op?: string;
|
|
46
|
+
value?: Literal | Literal[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Grouping
|
|
51
|
+
*/
|
|
52
|
+
export interface GroupNode extends Node<'group'> {
|
|
53
|
+
op?: 'and' | 'or';
|
|
54
|
+
value: AllNode[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unary node
|
|
59
|
+
*/
|
|
60
|
+
export interface UnaryNode extends Node<'unary'> {
|
|
61
|
+
op?: 'not';
|
|
62
|
+
value: AllNode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Array node
|
|
67
|
+
*/
|
|
68
|
+
export interface ArrayNode extends Node<'list'> {
|
|
69
|
+
op?: 'not';
|
|
70
|
+
value: Literal[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AllNode = ArrayNode | UnaryNode | GroupNode | ClauseNode;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Translation of operators to model query keys
|
|
77
|
+
*/
|
|
78
|
+
export const OP_TRANSLATION: Record<string, string> = {
|
|
79
|
+
'<': '$lt', '<=': '$lte',
|
|
80
|
+
'>': '$gt', '>=': '$gte',
|
|
81
|
+
'!=': '$ne', '==': '$eq',
|
|
82
|
+
'~': '$regex', '!': '$not',
|
|
83
|
+
in: '$in', 'not-in': '$nin'
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const VALID_OPS = new Set(Object.keys(OP_TRANSLATION));
|