@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 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
+ }
@@ -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));